summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/gen-of-sanctoral-readings.py136
1 files changed, 136 insertions, 0 deletions
diff --git a/scripts/gen-of-sanctoral-readings.py b/scripts/gen-of-sanctoral-readings.py
new file mode 100644
index 0000000..fd9c035
--- /dev/null
+++ b/scripts/gen-of-sanctoral-readings.py
@@ -0,0 +1,136 @@
+#!/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\b", h):
+ c = [strip_tags(x) for x in re.findall(r"<td\b[^>]*>(.*?)</td>", 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()