#!/usr/bin/env python3 """Add PROPER Mass readings to the Ordinary Form sanctoral (internal/caldata/roman-calendar.ini) from catholic-resources.org's Proper of Saints table (Fr. Felix Just, S.J.). Without this, an OF feast/solemnity or a proper-reading memorial (e.g. the Apostles' feasts, the Assumption, All Saints, St Barnabas, Our Lady of Sorrows, the Guardian Angels) has no readings of its own and falls back to the day's ferial. Only celebrations whose readings are marked PROPER (and every feast / solemnity) are imported; memorials that draw on a Common keep the ferial, which is the OF default. Scripture CITATIONS only; text is rendered from the public- domain corpora. See NOTICE. Requires network. From the repo root: python3 scripts/gen-of-sanctoral-readings.py Adds reading.first / reading.psalm / reading.second / reading.gospel lines to the matching entries in internal/caldata/roman-calendar.ini. """ import re, sys, html, urllib.request INI = "internal/caldata/roman-calendar.ini" URL = "https://www.catholic-resources.org/Lectionary/2002USL-Sanctoral.htm" MONS = {m: i + 1 for i, m in enumerate("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split())} # CR abbreviation -> lectio full book name (same table as genlect-of-cr.py). BOOK = {} def reg(full, *al): for a in al: BOOK[a.lower().replace(".", "").strip()] = full reg("Genesis","gen");reg("Exodus","exod","ex");reg("Leviticus","lev");reg("Numbers","num") reg("Deuteronomy","deut");reg("Joshua","josh");reg("Judges","judg");reg("Ruth","ruth") reg("1 Samuel","1 sam");reg("2 Samuel","2 sam");reg("1 Kings","1 kgs");reg("2 Kings","2 kgs") reg("1 Chronicles","1 chr");reg("2 Chronicles","2 chr");reg("Ezra","ezra");reg("Nehemiah","neh") reg("Tobit","tob");reg("Judith","jdt");reg("Esther","esth");reg("1 Maccabees","1 macc");reg("2 Maccabees","2 macc") reg("Job","job");reg("Psalms","ps","pss");reg("Proverbs","prov");reg("Ecclesiastes","eccl") reg("Song of Solomon","song","cant");reg("Wisdom","wis");reg("Sirach","sir") reg("Isaiah","isa");reg("Jeremiah","jer");reg("Lamentations","lam");reg("Baruch","bar") reg("Ezekiel","ezek");reg("Daniel","dan");reg("Hosea","hos");reg("Joel","joel");reg("Amos","amos") reg("Obadiah","obad");reg("Jonah","jonah");reg("Micah","mic","micah");reg("Nahum","nah");reg("Habakkuk","hab") reg("Zephaniah","zeph");reg("Haggai","hag");reg("Zechariah","zech");reg("Malachi","mal") reg("Matthew","matt","mat");reg("Mark","mark");reg("Luke","luke");reg("John","john") reg("The Acts","acts");reg("Romans","rom");reg("1 Corinthians","1 cor");reg("2 Corinthians","2 cor") reg("Galatians","gal");reg("Ephesians","eph");reg("Philippians","phil");reg("Colossians","col") reg("1 Thessalonians","1 thess");reg("2 Thessalonians","2 thess");reg("1 Timothy","1 tim");reg("2 Timothy","2 tim") reg("Titus","titus");reg("Philemon","phlm");reg("Hebrews","heb");reg("James","jas") reg("1 Peter","1 pet");reg("2 Peter","2 pet");reg("1 John","1 john");reg("2 John","2 john");reg("3 John","3 john") reg("Jude","jude");reg("Revelation","rev","apoc") def clean_cite(raw): s = html.unescape(raw or "") s = re.sub(r"[–—]", "-", s) s = re.sub(r"\(#[^)]*\)", "", s) # drop lectionary numbers s = re.sub(r"\bo?r\s+elsewhere.*", "", s, flags=re.I) # cycle-specific gospel "A: ... B: ... C: ..." -> take the A option m = re.search(r"\bA:\s*(.+?)(?:\s+B:|$)", s) if m: s = m.group(1) s = re.split(r"\s+or\b", s, maxsplit=1)[0] # first of alternative readings s = s.strip().strip(";,").strip() if not s or s in (".", "x") or s.startswith("[") or s.startswith("("): return "" m = re.match(r"((?:[1-4]\s+)?[A-Za-z][A-Za-z]*\.?)\s*(.*)", s) if not m: return "" tok = m.group(1).lower().replace(".", "").strip() full = BOOK.get(tok) if not full: sys.stderr.write(f" ! unknown book {m.group(1)!r} in {raw!r}\n") return "" verses = re.sub(r",\s+", ",", m.group(2)).replace(" ", "") return f"{full} {verses}".strip() def strip_tags(s): return html.unescape(re.sub(r"<[^>]+>", "", s)).replace("\xa0", " ").replace("\n", " ").strip() def scrape(): req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0 (X11; Linux) lectio"}) h = urllib.request.urlopen(req, timeout=60).read().decode("utf-8", "replace") datere = re.compile(r"\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(\d{1,2})\b") rows = [] for tr in re.split(r"]*>(.*?)", tr, re.S)] if len(c) < 10: continue m = datere.search(c[1]) if not m: continue mmdd = f"{MONS[m.group(1)]:02d}-{int(m.group(2)):02d}" rank, src = c[3], c[4] if rank.startswith("USA") or "Calif" in rank: continue # national additions not in the universal calendar if not ("PROPER" in src.upper() or "Solemnity" in rank or "Feast" in rank): continue # Common-based memorial -> keep the ferial rows.append((mmdd, c[2], "vigil" in c[2].lower(), {"first": clean_cite(c[5]), "psalm": clean_cite(c[6]), "second": clean_cite(c[7]), "gospel": clean_cite(c[9])})) # one Mass per date: prefer the day Mass over a vigil; later row breaks ties best = {} for mmdd, name, isvig, parts in rows: if not (parts["first"] and parts["gospel"]): continue # need at least first + gospel cur = best.get(mmdd) if cur is None or (cur[0] and not isvig): # replace a vigil with a day Mass best[mmdd] = (isvig, name, parts) return {k: v[2] for k, v in best.items()} def main(): props = scrape() sys.stderr.write(f"scraped {len(props)} proper-reading celebrations\n") text = open(INI).read() head = text[:text.index("\n[")] blocks = re.split(r"\n(?=\[)", text[text.index("\n[") + 1:]) # index blocks by date + name out_blocks = [] used = set() for b in blocks: m = re.match(r"\[(.+?)\]", b) if not m or m.group(1) == "layer": out_blocks.append(b.rstrip("\n")) continue f = dict(re.findall(r"^([\w.]+)\s*=\s*(.+)$", b, re.M)) date = f.get("date", "") # strip any pre-existing reading.* lines (idempotent re-run) b = re.sub(r"^reading\.\w+\s*=.*\n?", "", b, flags=re.M).rstrip("\n") if date in props: p = props[date] add = "".join(f"\nreading.{part} = {p[part]}" for part in ("first", "psalm", "second", "gospel") if p.get(part)) b += add used.add(date) out_blocks.append(b) open(INI, "w").write(head.rstrip("\n") + "\n\n" + "\n\n".join(out_blocks) + "\n") missing = sorted(set(props) - used) sys.stderr.write(f"added readings to {len(used)} entries; " f"{len(missing)} CR dates had no matching universal-calendar entry: {missing}\n") if __name__ == "__main__": main()