From f0bfe1efb93e04e0e3b9a33b8b085fc09e86af36 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 10:54:28 +0200 Subject: fix(of): partial-proper memorials compose proper part + ferial (match USCCB) A memorial marked 'PROPER Gospel' (Guardian Angels, Mary Magdalene, Our Lady of Sorrows, Joseph the Worker, the Passion of John the Baptist, Sts Martha/Mary/ Lazarus) uses the FERIAL first reading + its proper gospel; one marked 'PROPER First Reading' (Barnabas) uses its proper first + the ferial gospel. Previously we imported catholic-resources' *suggested* first for the gospel-proper ones, which differs from what USCCB publishes. - readings.go: when the observed's inline Mass has only a proper gospel (or only a proper first), fill the missing reading/psalm from the day's ferial and sort into canonical order. A fully-proper feast is untouched. Factored out ofFerial(). - gen-of-sanctoral-readings.py: keep only the strictly-proper part per the CR source-note. Verified vs USCCB: Guardian Angels (2025-10-02) now = Nehemiah 8 / Ps 19 / Matt 18:1-5,10 exactly. Updates TestUniversalSanctoralReadings. --- internal/caldata/caldata_test.go | 27 +++++++++++++--------- internal/caldata/readings.go | 43 +++++++++++++++++++++++++++++------- internal/caldata/roman-calendar.ini | 13 ----------- scripts/gen-of-sanctoral-readings.py | 21 +++++++++++++----- 4 files changed, 67 insertions(+), 37 deletions(-) diff --git a/internal/caldata/caldata_test.go b/internal/caldata/caldata_test.go index 0d6616e..12aa765 100644 --- a/internal/caldata/caldata_test.go +++ b/internal/caldata/caldata_test.go @@ -33,13 +33,16 @@ func TestUniversalLoads(t *testing.T) { func TestUniversalSanctoralReadings(t *testing.T) { l := Universal() - // A feast/solemnity or a PROPER-reading memorial carries its own inline Mass - // (imported from catholic-resources.org) rather than falling back to the ferial. - want := map[string][2]string{ // slug -> {reading.first substr, reading.gospel substr} - "barnabas-the-apostle": {"Acts 11", "Matthew 10:7-13"}, + // A feast/solemnity or a PROPER-reading memorial carries its own inline Mass. + // Partial-proper memorials keep ONLY the strictly-proper part (the resolver + // fills the rest from the ferial): the Guardian Angels a proper gospel, St + // Barnabas a proper first reading, Our Lady of Sorrows a proper gospel. + want := map[string][2]string{ // slug -> {reading.first substr, reading.gospel substr; "" = absent} "assumption-of-the-blessed-virgin-mary": {"Revelation 11", "Luke 1:39-56"}, - "our-lady-of-sorrows": {"Hebrews 5", "John 19:25-27"}, "all-saints": {"Revelation 7", "Matthew 5:1-12"}, + "barnabas-the-apostle": {"Acts 11", ""}, + "guardian-angels": {"", "Matthew 18:1-5"}, + "our-lady-of-sorrows": {"", "John 19:25-27"}, } for slug, w := range want { rc, ok := l.Cels[slug] @@ -47,11 +50,15 @@ func TestUniversalSanctoralReadings(t *testing.T) { t.Errorf("missing %q", slug) continue } - if got := rc.Fields["reading.first"]; !strings.Contains(got, w[0]) { - t.Errorf("%s: reading.first = %q, want containing %q", slug, got, w[0]) - } - if got := rc.Fields["reading.gospel"]; !strings.Contains(got, w[1]) { - t.Errorf("%s: reading.gospel = %q, want containing %q", slug, got, w[1]) + for i, part := range []string{"reading.first", "reading.gospel"} { + got := rc.Fields[part] + if w[i] == "" { + if got != "" { + t.Errorf("%s: %s = %q, want absent (ferial)", slug, part, got) + } + } else if !strings.Contains(got, w[i]) { + t.Errorf("%s: %s = %q, want containing %q", slug, part, got, w[i]) + } } } } diff --git a/internal/caldata/readings.go b/internal/caldata/readings.go index 49109f5..1b87b7a 100644 --- a/internal/caldata/readings.go +++ b/internal/caldata/readings.go @@ -1,11 +1,25 @@ package caldata import ( + "sort" "time" "github.com/lukaszkasprzak/lectio/internal/calendar" ) +var partOrder = map[string]int{"first": 0, "psalm": 1, "second": 2, "acclamation": 3, "gospel": 4} + +// ofFerial returns the day's Ordinary-Form ferial (weekday) readings, or nil. +func ofFerial(sel calendar.Selection, date time.Time, day calendar.LiturgicalDay) []calendar.Reading { + if day.WeekdayCycle == "" || date.Weekday() == time.Sunday { + return nil + } + if temp := calendar.Compute(date, sel, nil); temp.Observed.Layer == "temporal" { + return TemporalReadings("new", temp.Observed.Slug+"-"+day.WeekdayCycle) + } + return nil +} + // Readings resolves a day's Mass readings: the observed celebration's inline // propers, else the temporal lectionary for its slug, else -- for an EF // weekday with no proper Mass -- the preceding Sunday's Mass (the 1962 rule @@ -17,6 +31,24 @@ func Readings(sel calendar.Selection, layers []calendar.Layer, date time.Time, d rs = append(rs, m.Readings...) } if len(rs) > 0 { + // OF: a memorial with only a PROPER gospel (or only a proper first) takes + // the missing reading from the day's ferial -- e.g. the Guardian Angels are + // the ferial first reading + their proper gospel (Matt 18), and St Barnabas + // is his proper first (Acts 11) + the ferial gospel. A fully-proper feast + // (first AND gospel present) is left untouched. + have := map[string]bool{} + for _, r := range rs { + have[r.Part] = true + } + if sel.Form != "old" && !(have["first"] && have["gospel"]) { + for _, fr := range ofFerial(sel, date, day) { + if p := fr.Part; !have[p] && (p == "first" || p == "psalm" || p == "gospel") { + rs = append(rs, fr) + have[p] = true + } + } + sort.SliceStable(rs, func(i, j int) bool { return partOrder[rs[i].Part] < partOrder[rs[j].Part] }) + } return rs } // temporal table: EF by bare slug; OF by slug+cycle. Sundays & solemnities @@ -39,14 +71,9 @@ func Readings(sel calendar.Selection, layers []calendar.Layer, date time.Time, d } // OF: a sanctoral memorial / optional memorial with no proper readings of its // own reads the FERIAL (weekday) readings of the day -- the ordinary OF rule. - // Compute the pure temporal (no sanctoral) to get the day's ferial slug. - if sel.Form != "old" && day.Observed.Layer != "temporal" && - day.WeekdayCycle != "" && date.Weekday() != time.Sunday { - temp := calendar.Compute(date, sel, nil) - if temp.Observed.Layer == "temporal" { - if r := TemporalReadings("new", temp.Observed.Slug+"-"+day.WeekdayCycle); len(r) > 0 { - return r - } + if sel.Form != "old" && day.Observed.Layer != "temporal" { + if r := ofFerial(sel, date, day); len(r) > 0 { + return r } } // EF: a green-season weekday with no proper Mass repeats the preceding Sunday. diff --git a/internal/caldata/roman-calendar.ini b/internal/caldata/roman-calendar.ini index ccffa64..f82224f 100644 --- a/internal/caldata/roman-calendar.ini +++ b/internal/caldata/roman-calendar.ini @@ -492,8 +492,6 @@ class = saint colour = white name.en = Saint Joseph the Worker name.la = S. Ioseph Opificis -reading.first = Genesis 1:26-2:3 -reading.psalm = Psalms 90:2,3-4,12-13,14+16 reading.gospel = Matthew 13:54-58 [athanasius-bishop-and-doctor] @@ -698,7 +696,6 @@ name.pl = św. Barnaby, apostoła name.la = S. Barnabae, apostoli reading.first = The Acts 11:21b-26;13:1-3 reading.psalm = Psalms 98:1,2-3ab,3cd-4,5-6 -reading.gospel = Matthew 10:7-13 [anthony-of-padua-priest-and-doctor] date = 06-13 @@ -901,8 +898,6 @@ colour = white name.en = Saint Mary Magdalene name.pl = św. Marii Magdaleny name.la = S. Mariae Magdalenae -reading.first = Song of Solomon 3:1-4b -reading.psalm = Psalms 63:2,3-4,5-6,8-9 reading.gospel = John 20:1-2,11-18 [birgitta-religious] @@ -953,8 +948,6 @@ colour = red name.en = Saint Martha name.pl = św. Marty, Marii i Łazarza name.la = S. Marthae -reading.first = 1 John 4:7-16 -reading.psalm = Psalms 34:2-3,4-5,6-7,8-9,10-11 reading.gospel = John 11:19-27 [peter-chrysologus-bishop-and-doctor] @@ -1224,8 +1217,6 @@ colour = red name.en = The Beheading of Saint John the Baptist, martyr name.pl = męczeństwa św. Jana Chrzciciela name.la = In Passione S. Ioannis Baptistae, martyris -reading.first = Jeremiah 1:17-19 -reading.psalm = Psalms 71:1-2,3-4a,5-6ab,15ab+17 reading.gospel = Mark 6:17-29 [gregory-the-great-pope-and-doctor] @@ -1294,8 +1285,6 @@ colour = white name.en = Our Lady of Sorrows name.pl = Najświętszej Maryi Panny Bolesnej name.la = B. Mariae Virginis Perdolentis -reading.first = Hebrews 5:7-9 -reading.psalm = Psalms 31:2-3b,3cd-4,5-6,15-16,20 reading.gospel = John 19:25-27 [saints-cornelius-pope-and-cyprian-bishop-martyrs] @@ -1423,8 +1412,6 @@ colour = white name.en = Guardian Angels name.pl = świętych Aniołów Stróżów name.la = Ss. Angelorum Custodum -reading.first = Exodus 23:20-23 -reading.psalm = Psalms 91:1-2,3-4ab,4c-6,10-11 reading.gospel = Matthew 18:1-5,10 [francis-of-assisi] diff --git a/scripts/gen-of-sanctoral-readings.py b/scripts/gen-of-sanctoral-readings.py index fd9c035..a37a0c1 100644 --- a/scripts/gen-of-sanctoral-readings.py +++ b/scripts/gen-of-sanctoral-readings.py @@ -85,18 +85,27 @@ def scrape(): continue mmdd = f"{MONS[m.group(1)]:02d}-{int(m.group(2)):02d}" rank, src = c[3], c[4] + su = src.upper() 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): + if not ("PROPER" in su 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])})) + parts = {"first": clean_cite(c[5]), "psalm": clean_cite(c[6]), + "second": clean_cite(c[7]), "gospel": clean_cite(c[9])} + # PARTIAL propers: the source-note marks which part is strictly proper; the + # OTHER reading is the ferial (the resolver fills it at runtime). A memorial + # with a "PROPER Gospel" keeps ONLY the gospel (Guardian Angels = ferial + # first + Matt 18); a "PROPER First Reading" keeps only the first (Barnabas). + if "PROPER" in su and "GOSPEL" in su and "FIRST" not in su: + parts = {"first": "", "psalm": "", "second": "", "gospel": parts["gospel"]} + elif "PROPER" in su and "FIRST" in su and "GOSPEL" not in su: + parts = {"first": parts["first"], "psalm": parts["psalm"], "second": "", "gospel": ""} + rows.append((mmdd, c[2], "vigil" in c[2].lower(), parts)) # 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 + if not (parts["first"] or parts["gospel"]): + continue # need at least the proper part 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) -- cgit v1.3