#!/usr/bin/env python3 """Re-source the Ordinary Form SUNDAY & temporal-solemnity lectionary (the 3-year A/B/C cycle) from catholic-resources.org (Fr. Felix Just, S.J.). The niedziela harvest dropped the SECOND reading on 271 of ~299 Sunday-cycle entries (Sunday Mass = first/psalm/second/gospel). catholic-resources.org gives the complete readings, keyed by liturgical position. This replaces the temporal Sunday and temporal-solemnity entries (Ordinary Time, Advent, Christmas, Lent, Easter, Trinity, Corpus Christi, Sacred Heart, Christ the King). Sanctoral solemnities/feasts (All Saints, the Assumption, ...) are NOT touched here -- they carry inline readings in roman-calendar.ini. Citations only; see NOTICE. Requires network. From the repo root: python3 scripts/genlect-of-sundays-cr.py Rewrites the matching - entries in internal/caldata/of-lectionary.ini. """ import re, sys, html, urllib.request INI = "internal/caldata/of-lectionary.ini" PAGES = ["OrdinaryA", "OrdinaryB", "OrdinaryC", "Advent", "Christmas", "Lent", "Easter", "Solemnities"] BASE = "https://www.catholic-resources.org/Lectionary/1998USL-{}.htm" 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") # CR Sunday-page abbreviation variants (the Sunday tables abbreviate more tersely) reg("Jonah","jon");reg("The Acts","act");reg("Hebrews","hebr");reg("Isaiah","is") reg("John","jn");reg("Matthew","mt");reg("Zechariah","zac");reg("Song of Solomon","songs") reg("1 Peter","1 petr");reg("2 Maccabees","2 mac");reg("Ezekiel","ez") # also accept a spelled-out book name as its own alias (some cells use full names) for _full in list(set(BOOK.values())): BOOK.setdefault(_full.lower(), _full) def clean_cite(raw): s = html.unescape(raw or "") s = re.sub(r"[–—]", "-", s) s = re.sub(r"\([^)]*\)", "", s) # drop parenthetical notes: (#740), (cited…), (diff), (new)… s = re.sub(r"^(opt\.?:|optional:|\d+\))\s*", "", s.strip(), flags=re.I) # cycle-proper "opt:" alternate / numbered option s = re.split(r"\s+or\b", s, maxsplit=1)[0] s = re.sub(r"^(cf\.|see)\s+", "", s.strip(), flags=re.I).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 "" full = BOOK.get(m.group(1).lower().replace(".", "").strip()) if not full: return "" return f"{full} {re.sub(r',\s+', ',', m.group(2)).replace(' ', '')}".strip() def slug_of(name): n = re.sub(r"\s+", " ", name.lower()) if "vigil" in n: # a Sunday's vigil Mass -> use the day Mass row instead return None # specific solemnity phrases FIRST (the Year-A rows are verbose, e.g. "Friday # after the Second Sunday after Pentecost: ... Sacred Heart", so a loose # "pentecost"/"trinity" test would mis-fire). if "sacred heart" in n: return "sacred-heart" if "body and blood" in n or "corpus christi" in n: return "corpus-christi" if "most holy trinity" in n or "trinity sunday" in n: return "trinity-sunday" if "christ the king" in n or ("king of the universe" in n): return "christ-the-king" if "holy family" in n: return "holy-family" if "baptism of the lord" in n: return "baptism-of-the-lord" if "epiphany of the lord" in n: return "epiphany" if "mother of god" in n: return "mary-mother-of-god-octave-of-christmas" if "ascension of the lord" in n: return "ascension" if "day of pentecost" in n or "pentecost sunday" in n or n.startswith("pentecost"): return "pentecost" m = re.search(r"(\d+)\w* sunday in ordinary time", n) if m: return f"ordinary-sunday-{m.group(1)}" m = re.search(r"(\d+)\w* sunday of advent", n) if m: return f"advent-sunday-{m.group(1)}" m = re.search(r"(\d+)\w* sunday of lent", n) if m: return f"lent-sunday-{m.group(1)}" m = re.search(r"(\d+)\w* sunday of easter", n) if m: k = int(m.group(1)) return "easter-sunday" if k == 1 else "easter-octave-sun" if k == 2 else f"easter-sunday-{k}" if "palm sunday" in n or "passion sunday" in n: return "palm-sunday" if "resurrection of the lord" in n: return "easter-sunday" # Mass of Easter Day if "second sunday after christmas" in n or "sunday within the octave of christmas" in n: return "christmas-sunday-sun" return None # Nativity Day masses, Triduum, etc. -> not a cycle-Sunday def strip_tags(s): return html.unescape(re.sub(r"<[^>]+>", "", s)).replace("\xa0", " ").replace("\n", " ").strip() def scrape_page(name): req = urllib.request.Request(BASE.format(name), headers={"User-Agent": "Mozilla/5.0 (X11; Linux) lectio"}) h = urllib.request.urlopen(req, timeout=60).read().decode("utf-8", "replace") out = {} for tr in re.split(r"]*>(.*?)", tr, re.S)] if len(c) < 8 or c[0] == "Date": continue # a name may carry an appended "[2025: Corpus Christi…]" note (this Sunday # is displaced that year); strip a bracketed note that follows real text so # slug_of matches the Sunday, not a word inside the note. name = re.sub(r"(?<=\S)\s*\[[^\]]*\]", "", c[2].replace("\r", " ")) cyc = re.search(r"[-–—]\s*(ABC|[ABC])\b", name) # hyphen OR en/em dash; ABC = all cycles slug = slug_of(name) if not slug: continue parts = {"first": clean_cite(c[3]), "psalm": clean_cite(c[4]), "second": clean_cite(c[5]), "gospel": clean_cite(c[7])} if not parts["first"] and not parts["second"]: continue # a procession/notes row with no Mass readings if slug == "palm-sunday": # Palm Sunday's Mass gives cycle-independent first/psalm/second; the # gospel (the Passion) is per cycle, so keep whatever lectio already # has. Apply the readings to all three cycles, without a gospel. for cy in ("A", "B", "C"): out[f"palm-sunday-{cy}"] = {**parts, "gospel": ""} continue if not cyc: continue if cyc.group(1) == "ABC": for cy in ("A", "B", "C"): out.setdefault(f"{slug}-{cy}", parts) # ABC only fills a cycle not given explicitly else: out[f"{slug}-{cyc.group(1)}"] = parts # a specific cycle row always wins return out def firstbook(c): # book + chapter, for validation m = re.match(r"((?:[1-4] )?[A-Za-z ]+?) (\d+)", c or "") return f"{m.group(1)} {m.group(2)}" if m else (c or "") def main(): cr = {} for p in PAGES: d = scrape_page(p) sys.stderr.write(f"{p}: {len(d)} mapped Sunday entries\n") cr.update(d) text = open(INI).read() head = text[:text.index("\n[")] blocks = {} order = [] for b in re.split(r"\n(?=\[)", text[text.index("\n[") + 1:]): m = re.match(r"\[(.+?)\]", b) key = m.group(1) if m else b blocks[key] = b.rstrip("\n"); order.append(key) changed = firstdiff = added = 0 for key, p in cr.items(): if not (p["second"] or p["first"]): # nothing useful to merge continue old = dict(re.findall(r"^(\w+)\s*=\s*(.+)$", blocks.get(key, ""), re.M)) if old.get("first") and p["first"] and firstbook(old["first"]) != firstbook(p["first"]): firstdiff += 1 sys.stderr.write(f" ~ {key}: first {old['first']!r} -> {p['first']!r}\n") if "second" not in old and p["second"]: changed += 1 merged = {part: (p[part] or old.get(part, "")) for part in ("first", "psalm", "second", "gospel")} if not (merged["first"] and merged["gospel"]): # never write an incomplete Sunday continue block = "[" + key + "]\n" + "\n".join(f"{part} = {merged[part]}" for part in ("first", "psalm", "second", "gospel") if merged[part]) if key not in blocks: order.append(key); added += 1 blocks[key] = block open(INI, "w").write(head.rstrip("\n") + "\n\n" + "\n\n".join(blocks[k] for k in order) + "\n") sys.stderr.write(f"\nmerged Sunday entries; {changed} gained a 2nd reading, {added} new, " f"{firstdiff} first-reading book changes (mapping check)\n") if __name__ == "__main__": main()