aboutsummaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral-ef.go
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/gen-sanctoral-ef.go')
-rw-r--r--scripts/gen-sanctoral-ef.go268
1 files changed, 236 insertions, 32 deletions
diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go
index e3ad18e..e5392d3 100644
--- a/scripts/gen-sanctoral-ef.go
+++ b/scripts/gen-sanctoral-ef.go
@@ -113,21 +113,40 @@ func rankOf(n int) calendar.Rank {
// classOf marks the feasts of the Lord (class lord). A II class feast of the
// Lord takes the place of a Sunday of the same class (1960 occurrence rules) —
-// e.g. the Purification (Presentation, Feb 2), the Exaltation of the Holy Cross
-// (Sep 14), the Dedication of the Lateran (Nov 9). Saint/BVM feasts of the same
-// class are only commemorated on a Sunday, so they need no marker.
+// e.g. the Exaltation of the Holy Cross (Sep 14), the Dedication of the
+// Archbasilica (Nov 9), the Commemoration of the Baptism (Jan 13). Saint/BVM
+// feasts of the same class are only commemorated on a Sunday, so they need no
+// marker.
+//
+// Two substring checks are deliberately narrower than they once were, and one
+// is new, all found by checking the calendarium's own verbatim titles
+// (missale-romanum-1962.pdf) against what this function produced:
+// - "purification" is NOT a lord check at all -- the calendarium's own
+// title is "IN PURIFICATIONE B. MARIAE VIRG.", a feast of the BLESSED
+// VIRGIN, not the Lord. It was wrongly matching here.
+// - "holy name" alone is ambiguous: it matches BOTH "Holy Name of Jesus"
+// (a feast of the Lord) and "Most Holy Name of Mary"/"Holy Name of Mary"
+// (a feast of the BVM, calendarium: "Sanctissimi Nominis Mariae") --
+// wrongly matching the latter too. Excluded whenever the title also
+// names Mary.
+// - "baptism" is a new case: "Commemoration of the Baptism of the Lord" (13
+// January, calendarium: "IN COMMEMORATIONE BAPTISMATIS D. N. I. C.") did
+// not match any existing case -- the HasSuffix check below requires "of
+// OUR Lord", but this title's own wording is "of THE Lord" -- so it was
+// missing the marker entirely.
func classOf(en string) string {
l := strings.ToLower(en)
switch {
case strings.Contains(l, "holy cross"), // Exaltation / Finding of the Holy Cross
strings.Contains(l, "transfiguration"),
- strings.Contains(l, "purification"), // the Presentation of the Lord
strings.Contains(l, "precious blood"),
- strings.Contains(l, "holy name"),
+ strings.Contains(l, "baptism"),
+ strings.Contains(l, "holy name") && !strings.Contains(l, "mary"),
strings.Contains(l, "dedication of the archbasilica"),
strings.Contains(l, "of our holy savior"),
strings.Contains(l, "of our lord jesus"),
- strings.HasSuffix(l, "of our lord"):
+ strings.HasSuffix(l, "of our lord"),
+ strings.HasSuffix(l, "of the lord"):
return "lord"
}
return ""
@@ -215,6 +234,32 @@ func idParts(id string) (calendar.Rank, string) {
return rankOf(n), colourOf(p[3])
}
+// idHomeDate extracts the "MM-DD" home date embedded in a missalemeum
+// info.id ("sancti:MM-DD[sfx]:RANK:COLOUR" -- sfx is an internal
+// disambiguator missalemeum sometimes appends, e.g. "01-28t", "11-09cc",
+// "11-02m1"; the first 5 characters are always the date). Returns "" if id
+// is too short to contain one.
+//
+// This is the fix for a class of bug the slug-collision fix above (in
+// main()) exposed rather than caused: a MOVABLE-transfer feast displayed on
+// whatever civil date it actually landed on in a given reference year (St
+// Joseph, 19 March, impeded by a Sunday of Lent and shown on the 20th; the
+// Annunciation deferred past Holy Week; All Souls moved to the Monday when 2
+// November is a Sunday; even St Matthias' fixed 24 February shown on the
+// 25th in a leap year) still carries its OWN proper date in info.id, not the
+// civil date queried. Comparing the two lets harvestDate recognise "this is
+// not really this date's own office" and skip it, instead of harvesting a
+// phantom fixed-date entry at the transferred civil date -- confirmed
+// live (`curl .../api/v5/proper/2023-03-20`): id "sancti:03-19:1:w" while
+// the date queried is 2023-03-20.
+func idHomeDate(id string) string {
+ p := strings.SplitN(id, ":", 2)
+ if len(p) < 2 || len(p[1]) < 5 {
+ return ""
+ }
+ return p[1][:5]
+}
+
func readingsFrom(d *mmDay) (first, gospel string) {
for _, s := range d.Sections {
if len(s.Body) == 0 || len(s.Body[0]) == 0 {
@@ -234,40 +279,165 @@ func readingsFrom(d *mmDay) (first, gospel string) {
return
}
+// knownSpuriousComm excludes specific missalemeum commemorations, keyed
+// "MM-DD/slug", that the 1962 calendarium's own row for that date does NOT
+// support -- confirmed by checking the primary text directly, not inferred.
+// 9 August's row reads only "Vigilia, III classis.", with no "Com." line, so
+// missalemeum's own "St. Romanus" commemoration that day has no calendarium
+// backing. This is a genuine upstream (missalemeum) data quirk, not something
+// derivable from the API response itself, so it is recorded here rather than
+// silently reproduced -- see the report for the primary-source citation.
+var knownSpuriousComm = map[string]bool{
+ "08-09/romanus": true,
+ // 14 August's row in the calendarium reads only "Vigilia, II classis.",
+ // no "Com." line; the genuine St Eusebius (Bishop and Martyr) is
+ // commemorated 16 December instead, where he is correctly present.
+ "08-14/eusebius": true,
+}
+
+// slugOverride gives a proper, distinct slug to a small number of
+// commemorations whose title slugifies IDENTICALLY to an unrelated feast on
+// a different fixed date. Confirmed against the calendarium: 28 January's
+// "St. Agnes" is the traditional SECOND commemoration of the 21 January
+// feast (the same saint, repeated, not a coincidence); 14 May's "St.
+// Boniface" is a different early martyr from 5 June's Boniface of Mainz, an
+// entirely different person whose title happens to abbreviate to the same
+// English string. Keyed "MM-DD/original-slug" -> replacement slug.
+var slugOverride = map[string]string{
+ "01-28/agnes": "agnes-secundo",
+ "05-14/boniface": "boniface-martyr",
+}
+
+// refYearExplainsAbsence reports whether the TEMPORAL day alone (no
+// sanctoral data at all -- an empty layer stack) on this specific reference
+// date was already strong enough that ANY class-1..4 saint would lose there
+// regardless of its own merit: a Sunday or a named I/II-class feast, an
+// Ember day, the late-Advent or Christmas-octave privilege (I or II class),
+// or a privileged Lent/Passiontide feria (III class, but still privileged
+// over an equal-or-lower-class saint per RG 109(e)).
+//
+// This is the discriminator between two different reasons a saint is never
+// OBSERVED in any of the six reference years:
+//
+// 1. Genuinely without an independent Mass in the 1960-reformed books --
+// reduced to an added commemoration on ANY day, including an ordinary,
+// unprivileged one. Confirmed live: St Blaise (3 Feb, an ordinary
+// Septuagesima-season feria, non-privileged), St Canute (19 Jan, an
+// ordinary Time-after-Epiphany feria) and others are shown by
+// missalemeum as mere commemorations even then -- this is real 1962
+// data, not a sampling artefact, and RankCommemoration is the correct,
+// honest rank for them.
+// 2. A real class-1..4 feast that merely never won in these six
+// PARTICULAR reference years because its fixed date happens to fall,
+// in every one of them, on a day already strong enough to beat any
+// saint of its class -- the 15-entry, 6 March-5 April case this fix
+// originally targeted (every one of those dates falls within Lent in
+// all six reference years). Here the id's own embedded rank is
+// trustworthy.
+//
+// harvestDate keeps a saint's commemoration id rank ONLY if every reference
+// year in which it was seen was case 1 above (i.e. this function returned
+// true every time) -- a single unprivileged-day counter-example is enough
+// to fall back to RankCommemoration.
+func refYearExplainsAbsence(date time.Time) bool {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ day := calendar.Compute(date, sel, nil)
+ if day.Observed.Rank == calendar.RankClass1 || day.Observed.Rank == calendar.RankClass2 {
+ return true
+ }
+ return day.Season == calendar.Lent || day.Season == calendar.Passiontide
+}
+
+// commTracker accumulates one commemoration slug's data across reference
+// years: the entry itself (first sighting's title/colour/id-rank), and
+// whether EVERY year it was seen in was "explained" by refYearExplainsAbsence.
+type commTracker struct {
+ entry entry
+ allExplained bool
+}
+
// harvestDate returns the observed sanctoral office for a fixed MM-DD (nil if
// the date is always a feria/temporal) plus any sancti commemorations seen.
+//
+// Every one of the `years` reference years is scanned for BOTH the observed
+// office and commemorations -- neither loop exits early on the first hit.
+// Two real bugs lived in an earlier version that DID exit early:
+//
+// 1. Returning as soon as the FIRST reference year showed an observed
+// office discarded every commemoration that only showed up in a LATER
+// year (e.g. 9 November: 2025, the first year tried, happens to be the
+// one year of six with no "St. Theodore" commemoration alongside the
+// Dedication of the Archbasilica; a `return` there drops Theodore for
+// good).
+// 2. The tempFeastSkip/Christ-the-King check used to `return nil, comms`
+// outright -- correct for tempFeastSkip's three permanently-fixed dates
+// (every year behaves the same, so nothing is lost), but wrong for
+// Christ the King, which occupies a given MM-DD only in the one
+// reference year it happens to be the last Sunday of October (2025 for
+// 26 October, in this generator's own reference years): returning
+// immediately there discarded "St. Evaristus", visible only in the OTHER
+// five years. `continue` fixes both: the loop keeps trying every
+// remaining year regardless of what any single year showed.
func harvestDate(mmdd string) (*entry, []entry) {
- var comms []entry
- seenComm := map[string]bool{}
+ commTrack := map[string]*commTracker{}
+ var obs *entry
for _, y := range years {
date := fmt.Sprintf("%04d-%s", y, mmdd)
- if _, err := time.Parse("2006-01-02", date); err != nil {
+ refDate, err := time.Parse("2006-01-02", date)
+ if err != nil {
continue // e.g. 02-29 in a common year
}
d, err := fetch(date)
if err != nil {
continue
}
+ explained := refYearExplainsAbsence(refDate)
for _, c := range d.Info.Commemorations {
if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) {
continue
}
+ if home := idHomeDate(c.ID); home != "" && home != mmdd {
+ continue // a transferred feast's commemoration, not a genuine one for THIS date
+ }
slug := slugify(c.Title)
- if slug == "" || seenComm[slug] {
+ if slug == "" || knownSpuriousComm[mmdd+"/"+slug] {
continue
}
- seenComm[slug] = true
- _, col := idParts(c.ID)
- // A saint never OBSERVED in any harvest year — only ever commemorated —
- // is a commemoration in the 1962 universal calendar: it does NOT have
- // its own Mass and yields to the ferial office (which is celebrated with
- // the saint commemorated). Rank it RankCommemoration, not the id's class
- // (missalemeum reuses class-3/4 for these), so a genuine IV class feast
- // still outranks the feria while a commemoration does not. If the same
- // slug is observed in another year, that observed entry supersedes this.
- comms = append(comms, entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: calendar.RankCommemoration})
+ if t, ok := commTrack[slug]; ok {
+ if !explained {
+ t.allExplained = false
+ }
+ continue // title/colour/id-rank already captured from the first sighting
+ }
+ // The COMMEMORATION object's own id names THAT SAINT's true rank
+ // (unlike the DAY's own info.id, which names the rank of whatever
+ // propers are reused that day, not the commemorated saint's) --
+ // idParts already extracts it; only the colour half used to be
+ // kept. Whether this rank is actually TRUSTED depends on
+ // `explained` across every year this slug is seen -- resolved
+ // after the year loop, see refYearExplainsAbsence's own doc
+ // comment.
+ rank, col := idParts(c.ID)
+ commTrack[slug] = &commTracker{
+ entry: entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: rank},
+ allExplained: explained,
+ }
+ }
+ if obs != nil {
+ continue // already have an observed office; keep scanning other years for MORE commemorations
}
if strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) {
+ if home := idHomeDate(d.Info.ID); home != "" && home != mmdd {
+ // A movable-transfer feast displayed on today's civil date in
+ // THIS particular reference year (St Joseph pushed to the
+ // 20th; the Annunciation deferred past Holy Week; All Souls
+ // moved to the Monday; St Matthias shown on the 25th in a
+ // leap year) -- not a genuine fixed office for mmdd itself.
+ // See idHomeDate's own doc comment for the live-verified
+ // evidence. Try the next reference year instead.
+ continue
+ }
// A few Lord's feasts live in missalemeum's sancti namespace but the
// EF temporal engine already computes them (Nativity, Circumcision,
// Epiphany); exclude them so they aren't duplicated in the sanctoral.
@@ -278,18 +448,31 @@ func harvestDate(mmdd string) (*entry, []entry) {
// Christ the King is movable (last Sunday of October) and computed
// by the temporal engine; missalemeum files it under sancti, so it
// would otherwise leak into the sanctoral at a spurious fixed date.
- return nil, comms
+ // `continue`, not `return`: this disqualifies only THIS year's
+ // observed-office candidacy, not the whole date (see doc comment).
+ continue
}
first, gospel := readingsFrom(d)
col := colourOf(strings.Join(d.Info.Colors, ""))
- return &entry{
+ obs = &entry{
slug: slugify(d.Info.Title), date: mmdd, colour: col, class: classOf(d.Info.Title),
en: d.Info.Title, first: first, gospel: gospel,
rank: rankOf(d.Info.Rank), observed: true,
- }, comms
+ }
}
}
- return nil, comms
+ comms := make([]entry, 0, len(commTrack))
+ for _, t := range commTrack {
+ e := t.entry
+ if !t.allExplained {
+ // At least one reference year showed this saint demoted even on
+ // an ordinary, unprivileged day -- genuinely commemoration-only
+ // (see refYearExplainsAbsence), not merely unlucky sampling.
+ e.rank = calendar.RankCommemoration
+ }
+ comms = append(comms, e)
+ }
+ return obs, comms
}
func main() {
@@ -331,11 +514,34 @@ func main() {
entries := map[string]entry{}
add := func(e entry) {
if cur, ok := entries[e.slug]; ok {
- if cur.observed && !e.observed {
- return // don't let a commemoration downgrade an observed office
- }
- if cur.observed && e.observed {
- return // first observed year wins
+ if cur.date == e.date {
+ // Same fixed date: this is the SAME feast, seen again in
+ // another reference year or pass -- the existing dedup rules
+ // apply (an observed office is never downgraded by a
+ // commemoration; the first observed year wins).
+ if cur.observed && !e.observed {
+ return
+ }
+ if cur.observed && e.observed {
+ return
+ }
+ } else {
+ // A DIFFERENT fixed date slugified to the identical string
+ // (e.g. "St. Boniface" on both 14 May and 5 June, or "St.
+ // Agnes" on both 21 and 28 January) -- two distinct
+ // celebrations, not the same one recurring. The map is keyed
+ // by slug, so silently keeping the first and dropping the
+ // second here is exactly how St Agnes secundo (28 Jan), St
+ // Boniface Martyr (14 May), and their like went missing
+ // before this fix. Disambiguate instead of dropping.
+ if ov, ok := slugOverride[e.date+"/"+e.slug]; ok {
+ e.slug = ov
+ } else {
+ e.slug = e.slug + "-" + strings.ReplaceAll(e.date, "-", "")
+ }
+ if _, stillCollides := entries[e.slug]; stillCollides {
+ return // extremely unlikely second collision; drop rather than clobber
+ }
}
}
entries[e.slug] = e
@@ -347,9 +553,7 @@ func main() {
}
for _, r := range results { // commemorations after, so observed offices win
for _, c := range r.comms {
- if _, ok := entries[c.slug]; !ok {
- add(c)
- }
+ add(c)
}
}