summaryrefslogtreecommitdiff
path: root/scripts/gen-of-sanctoral-readings.py
blob: fd9c0352d2cf95e8d0064a1b10394d03286a32ed (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
#!/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()