diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 09:46:54 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 09:46:54 +0200 |
| commit | 97f704b659ea6c72c4aa354378b347283ecc5cac (patch) | |
| tree | 2606730c373ba97ebec6bcf3b38ebb00f13c0a29 /scripts | |
| parent | 6641ce335de522e6d51c47ad9d14f69726650592 (diff) | |
| download | lectio-97f704b659ea6c72c4aa354378b347283ecc5cac.tar.gz lectio-97f704b659ea6c72c4aa354378b347283ecc5cac.zip | |
feat(of): proper Mass readings for the sanctoral (from catholic-resources.org)
OF feasts, solemnities, and proper-reading memorials had no readings of their own
and fell back to the day's ferial (Barnabas showed the ferial instead of Acts 11;
Our Lady of Sorrows / the Guardian Angels showed the ferial instead of their
proper gospel). scripts/gen-of-sanctoral-readings.py scrapes catholic-resources.org's
Proper of Saints table and inlines reading.first/psalm/second/gospel into
roman-calendar.ini for the 41 universal-calendar celebrations marked PROPER (plus
every feast/solemnity); memorials that draw on a Common keep the ferial, which is
the OF default. Handles the source quirks: 'or' alternatives (takes the first),
cycle-specific A:/B:/C: gospels (takes A), lectionary # refs, and vigil-vs-day
(keeps the day Mass). All 41 render; citations only. Adds
TestUniversalSanctoralReadings, credits the source in NOTICE, bumps to 0.41.0.
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/gen-of-sanctoral-readings.py | 136 |
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() |
