aboutsummaryrefslogtreecommitdiff
path: root/scripts/genlect-of-sundays-cr.py
blob: 84dd3f82257770a72deb590059ae5149acdf91b0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#!/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 <slug>-<A|B|C> 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\b", h):
        c = [strip_tags(x) for x in re.findall(r"<td\b[^>]*>(.*?)</td>", 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()