summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/build-oracle-ef.sh56
-rw-r--r--scripts/gen-sanctoral-ef.go472
2 files changed, 455 insertions, 73 deletions
diff --git a/scripts/build-oracle-ef.sh b/scripts/build-oracle-ef.sh
index 2864551..32cb385 100755
--- a/scripts/build-oracle-ef.sh
+++ b/scripts/build-oracle-ef.sh
@@ -1,38 +1,42 @@
#!/usr/bin/env bash
-# build-oracle-ef.sh — generate the EF (1962) regression oracle by fetching
-# missalemeum's per-date proper API (the same source lectio's trad scraper uses,
-# built on Divinum Officium data) for 2025-2026.
+# build-oracle-ef.sh — build the EF (1962) regression oracle from the committed
+# missalemeum snapshot (sources/snapshot.tar.gz, missalemeum/en/YYYY-MM-DD.json,
+# 2026-01-01 .. 2027-12-31, 730 days), not a live fetch, so the oracle is
+# reproducible offline and pinned to a known-good snapshot.
#
-# NOT run by `go test`. Requires network + curl + jq + GNU date. From repo root:
+# Three things about this data that cost hours to learn (see oracle_ef_test.go):
+# 1. info.id looks like "sancti:MM-DD:rank:colour", but its embedded rank is
+# the rank of the PROPERS USED that day, not the day's own rank (e.g.
+# 2026-01-02 is a class-4 feria whose id is "sancti:01-01:1:w" because it
+# reuses the Circumcision's propers). Extracted here for provenance only
+# -- never parse rank/colour out of it. Use info.rank/info.colors.
+# 2. info.colors is an array; 14 of 730 days carry two values (Gaudete/
+# Laetare "pv", Palm Sunday "rv", Good Friday "bv", Holy Saturday "vw").
+# The Go test compares by membership, not equality.
+# 3. A two-colour value on a weekday can be a proper-reuse artifact (a feria
+# inside Gaudete/Laetare week reusing the Sunday's own propers) rather
+# than a claim about that weekday's own colour.
+#
+# From repo root:
# scripts/build-oracle-ef.sh
# Writes internal/calendar/testdata/oracle-ef.json:
-# { "YYYY-MM-DD": {"tempora": "...", "title": "...", "rank": N, "colour": "w"} }
-# tempora is "" when missalemeum returns null (then the temporal identity is in
-# title); the Go test derives the season from tempora-or-title.
+# { "YYYY-MM-DD": {"id":"...", "tempora":"...", "title":"...", "rank":N, "colours":["w",...]} }
set -euo pipefail
out=internal/calendar/testdata/oracle-ef.json
-mkdir -p "$(dirname "$out")"
work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
-for y in 2025 2026; do
- d="$y-01-01"
- while [ "$(date -d "$d" +%Y)" = "$y" ]; do
- echo "$d"
- d=$(date -d "$d +1 day" +%F)
- done
-done > "$work/dates"
+tar xzf sources/snapshot.tar.gz -C "$work" missalemeum/en
-fetch() {
- local d="$1"
- local j
- j=$(curl -sf --max-time 25 "https://www.missalemeum.com/en/api/v5/proper/$d" || true)
- printf '%s' "$j" | jq -c --arg d "$d" \
- '{($d): (.[0].info | {id:(.id // ""), tempora:(.tempora // ""), title:.title, rank:.rank, colour:(.colors|join(""))})}' \
- 2>/dev/null || true
-}
-export -f fetch
+jq -s '
+ map({(.[0].info.date): {
+ id: (.[0].info.id // ""),
+ tempora: (.[0].info.tempora // ""),
+ title: .[0].info.title,
+ rank: .[0].info.rank,
+ colours: .[0].info.colors
+ }}) | add
+' "$work"/missalemeum/en/*.json > "$out"
-xargs -P 12 -I{} bash -c 'fetch "$@"' _ {} < "$work/dates" | jq -s 'add' > "$out"
echo "wrote $out ($(jq 'length' "$out") days)" >&2
-rm -rf "$work"
diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go
index e3ad18e..0a43869 100644
--- a/scripts/gen-sanctoral-ef.go
+++ b/scripts/gen-sanctoral-ef.go
@@ -10,8 +10,10 @@
// (Lectio) and Gospel (Evangelium) citations, plus any sancti co-celebrations
// listed as commemorations. Several reference years are tried per date so a
// saint whose date is a Sunday (or under a higher feast) in one year is still
-// captured, observed with its own readings, from another. Existing Latin names
-// in the file are preserved (missalemeum has no Latin titles).
+// captured, observed with its own readings, from another. Existing
+// non-English names (Polish, Latin, or any other language already present
+// in the file) are preserved verbatim -- missalemeum supplies English
+// titles only.
//
// One-time; requires network. Run from the repo root:
//
@@ -113,21 +115,83 @@ 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 were narrowed, and one is new, found by checking the
+// calendarium's own verbatim titles (missale-romanum-1962.pdf) against what
+// this function produced -- but "purification" is a THIRD, DELIBERATELY
+// DIFFERENT case, kept matching rather than narrowed, and the reason is
+// itself worth recording:
+//
+// - "purification" IS kept as a lord match, even though the calendarium's
+// own title is "IN PURIFICATIONE B. MARIAE VIRG." (a feast of the BLESSED
+// VIRGIN by name) -- a DECISION AGAINST that primary text, made on
+// occurrence-behaviour evidence, not a reading of it, and recorded as
+// such rather than dressed up as textually clean.
+//
+// The tag's actual job is not naming/colour categorisation: it exists
+// solely to drive the occurrence rule that separates RG 91 entry 14
+// ("Festa Domini II classis" -- takes an occurring II-class Sunday's
+// place outright) from entry 16 ("Festa II classis Ecclesiae universae,
+// quae non [sunt Domini]" -- merely commemorated on one), entry 15
+// ("Dominicae II classis") sitting between the two. Checked live against
+// missalemeum (this generator's own data source): the Purification
+// takes a II-class Sunday's place OUTRIGHT, commemorations EMPTY
+// (2014-02-02, 2020-02-02, 2025-02-02, 2031-02-02, 2042-02-02, all
+// fetched independently) -- entry 14's pattern, not entry 16's (control:
+// the Nativity of the BVM, an undisputed ordinary Marian feast, on a
+// Sunday -- 2019-09-08 -- shows the SUNDAY observed, the feast merely
+// commemorated, entry 16's own pattern). RG 112(b), "Officium, Missa aut
+// commemoratio de dominica excludit commemorationem... de festo vel
+// mysterio Domini, et vicissim" (a Sunday's office and a feast/mystery
+// OF THE LORD mutually exclude each other as commemorations), backs the
+// empty commemoration list independently of RG 91's table position.
+//
+// Genuine primary-text counter-evidence exists and is not discarded:
+// RG 120(b), "Adhibetur color albus... b) B. Mariae Virg., etiam in
+// benedictione et processione candelarum die 2 februarii" -- 2 February
+// is filed under the WHITE-colour rule's "B. Mariae Virg." heading,
+// kept separate there from 120(a)'s own "Domini" heading. Both textual
+// tests available (the calendarium's title, RG 120's own taxonomy)
+// point BVM; the occurrence-behaviour evidence points Domini. This is a
+// live, acknowledged disagreement with the primary text on the
+// strength of oracle evidence about what the day actually DOES, not a
+// claim that the text is wrong or ambiguous. See
+// internal/calendar/precedence_ef_repro_test.go's
+// TestPurificationBeatsFebruarySunday for the fixture this rests on --
+// committed, not merely asserted, since the deciding years (2 February
+// on a Sunday) fall outside this repo's own 2026-2027 oracle snapshot
+// window.
+//
+// - "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. Unlike the Purification, this one is NOT contested: its
+// occurrence behaviour matches the ordinary-BVM pattern too.
+//
+// - "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, "purification"), // the Presentation of the Lord -- occurrence-behaviour evidence, see doc comment
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 ""
@@ -169,6 +233,19 @@ type mmDay struct {
} `json:"sections"`
}
+// fetchOnce fetches one date's proper. KNOWN LIMITATION: missalemeum
+// returns one array element PER MASS on a date with more than one (25
+// December: three, ids ...m1/...m2/...m3) and this reads only `data[0]`,
+// the first -- so a commemoration attached specifically to a second or
+// third Mass (the calendarium's own "In secunda Missa: Commemoratio..."
+// pattern, e.g. St Anastasia on Christmas Day) is structurally invisible
+// to every caller of this function, not just harvestDate's own use of it.
+// See the PRIMARY-SOURCE NOTE above harvestDate for the fuller account --
+// in Anastasia's specific case this is moot (missalemeum's own
+// "commemorations" list is empty on all three of the date's records, not
+// just the first, so fixing this would not by itself recover her), but the
+// limitation is real and would matter for any date whose SECOND or third
+// Mass genuinely does carry a commemoration missalemeum's API records.
func fetchOnce(date string) (*mmDay, error) {
req, _ := http.NewRequest("GET", "https://www.missalemeum.com/en/api/v5/proper/"+date, nil)
req.Header.Set("User-Agent", ua)
@@ -200,9 +277,16 @@ func fetch(date string) (*mmDay, error) {
}
type entry struct {
- slug, date, colour, class, en, la, first, gospel string
- rank calendar.Rank
- observed bool // has readings / reliable rank
+ slug, date, colour, class, en, first, gospel string
+ rank calendar.Rank
+ observed bool // has readings / reliable rank
+ // otherNames holds every "name.<lang>" field already present in the
+ // existing file for this slug, EXCLUDING "name.en" (English always
+ // comes fresh from missalemeum, in `en` above). Keyed by the full
+ // field name (e.g. "name.pl") so main() can emit it verbatim without
+ // hardcoding a language list -- see main()'s own preservedNames
+ // comment for why a hardcoded list is exactly the bug this fixes.
+ otherNames map[string]string
}
// idParts splits "sancti:MM-DD[sfx]:RANK:COLOUR" into rank word and colour word.
@@ -215,6 +299,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 +344,246 @@ func readingsFrom(d *mmDay) (first, gospel string) {
return
}
+// PRIMARY-SOURCE NOTE (found and corrected during review): of the three
+// local scans this generator's citations are checked against
+// (docs/research/*.pdf in the sibling colitur repo), ONE --
+// "1962-06-23,…LT.pdf", the Archivum Liturgicum ELECTRONIC TRANSCRIPTION --
+// silently drops vigil commemorations that the other two, PHOTOGRAPHIC
+// scans of the actual 1962 Missale Romanum, both carry. Confirmed on four
+// entries: 7 August (Donatus), 9 August (Romanus), 14 August (Eusebius),
+// 25 December (Anastasia) -- all present in both photographic scans'
+// calendarium AND their own Proprium Sanctorum text ("Et fit
+// commemoratio S. Romani Mar-", "...S. Eusebii Con-", etc.), all silently
+// absent from the transcription. A `knownSpuriousComm` exclusion list once
+// stood here, built by checking ONLY the transcription and concluding two
+// of these four ("Romanus", "Eusebius" on 14 August) were spurious -- WRONG,
+// on evidence that itself was incomplete, not on a genuine absence. Treat
+// the photographic scans as the primary source and the electronic
+// transcription as a convenience index only; where they disagree, the scan
+// wins. (14 August's "St. Eusebius, Conf." and 16 December's "St. Eusebius,
+// Ep. et Mart." are two different people, both genuinely in the calendarium
+// -- see slugOverride below, not a reason to drop either.)
+//
+// Of the four confirming examples above, three (Donatus, Romanus, Eusebius)
+// are fixed by this generator as of this note. **25 December's Anastasia is
+// NOT, and cannot be from this data source alone** -- a different, NOT
+// generator-fixable limitation, recorded so the next person does not
+// mistake her continued absence for an oversight of the fix above (she was
+// also missing at the branch point, so this is not a regression either):
+// the photographic scan places her "In secunda Missa: Commemoratio S.
+// Anastasiae Mart." -- specifically the SECOND of Christmas Day's three
+// Masses (missalemeum's own "2026-12-25" query independently confirmed to
+// return exactly three records, ids ...m1/...m2/...m3). Two compounding
+// problems, not one: fetchOnce (below) reads only `data[0]`, the FIRST
+// Mass, so this harvester cannot structurally see a commemoration attached
+// to a date's second or third Mass at all, for ANY date, not just this
+// one -- but ALSO, checked directly (not merely inferred from the
+// symptom), missalemeum's OWN "commemorations" list is empty on all THREE
+// of the 25 December records, not just the first -- so even a fetchOnce
+// rewritten to merge all of a date's Masses would still not recover her:
+// missalemeum's own data lacks her here, the same "transcription-shaped"
+// gap as Donatus/Romanus/Eusebius, just in the live API rather than the
+// static PDF this time. Not fixed: no source this generator reads carries
+// her.
+
+// 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; 14 August's "St. Eusebius" (a Confessor, calendarium "S.
+// Eusebii Conf.") is likewise a different person from 16 December's "St.
+// Eusebius" (a Bishop and Martyr, calendarium "S. Eusebii Ep. et Mart."),
+// both genuinely commemorated, missalemeum giving both the same bare
+// English title. Keyed "MM-DD/original-slug" -> replacement slug.
+var slugOverride = map[string]string{
+ "01-28/agnes": "agnes-secundo",
+ "08-14/eusebius": "eusebius-confessor",
+ "05-14/boniface": "boniface-martyr",
+}
+
+// refYearExplainsAbsence is a RANK-BLIND SAMPLING HEURISTIC, not a rubric
+// evaluator: it does not know, and cannot know, the true class of the saint
+// it is being asked about -- only whether the TEMPORAL day alone (no
+// sanctoral data at all) on this one reference date looks privileged enough
+// that a saint failing to win there is unsurprising. It reports true for: 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 Lent/Passiontide feria
+// (III class, privileged per RG 109(e)).
+//
+// Two known imprecisions, recorded rather than silently accepted:
+//
+// - The Lent/Passiontide branch is privilege over an EQUAL-OR-LOWER-class
+// candidate only -- RG 109(e) does not let a mere III-class feria beat a
+// I- or II-class feast. Nothing in this codebase's actual data currently
+// exercises that gap (every saint this heuristic has ever been asked
+// about that is demoted throughout Lent is independently III class or
+// lower, per the calendarium), but the function does not itself enforce
+// it, so a future entry could reach it.
+// - It is a proxy for "why was this saint never observed", not a citation.
+// 17-31 December is excluded from ever trusting the id-derived rank
+// below, REGARDLESS of what this heuristic would otherwise say --
+// covering BOTH of round 1's own late-Advent/Christmas-octave rank
+// promotions, on two DIFFERENT strengths of evidence, recorded
+// separately because they are not the same case:
+// -- 26-31 December (RG 68(d)/(e), a positive citation): "die 29
+// decembris, fit commemoratio S. Thomae Episcopi et Mart.; die 31
+// decembris, fit commemoratio S. Silvestri I Papae et Conf." -- the
+// calendarium names each a bare "Commemoratio" with NO class of its
+// own, even though the DAY they fall on is II class (within the
+// Nativity Octave). Two REAL entries (Thomas Becket, Silvester) are
+// live here; see the call site's own comment.
+// -- 17-23 December (RG 91 entry 18, a PRECAUTIONARY exclusion, not a
+// positive citation): round 1 also promoted these late-Advent
+// ferias from class-3 to class-2 (same lineage, same coupling
+// shape as 26-31 December's own promotion). No live entry tests
+// this range today -- the sole sanctoral entry there, `thomas`
+// (21 December), is an OBSERVED class-2 feast reached via the
+// harvestDate `obs` path, not this function's id-rank-trust path
+// at all -- but unlike 26-31 December, there is no RG citation
+// stating that a saint commemorated here has NO independent class;
+// RG 91 entry 18 only ranks the FERIA, and (per defect 2's own
+// finding) a genuine class-2 FEAST commemorated here would
+// actually WIN against it (entry 16 above entry 18), so "the day
+// is class-2" does not reliably explain a class-2 saint's absence
+// the way it does for 26-31 December's own two named cases.
+// Excluded anyway, on the side of the KNOWN-safe default
+// (RankCommemoration) rather than risk repeating the identical
+// failure shape the day this range's own first real entry arrives.
+//
+// This is also a warning about a structural hazard, not just a boundary
+// fix: this function calls calendar.Compute, i.e. it reads the ENGINE's OWN
+// computed temporal ranks to decide what DATA to generate. A change to
+// temporal_ef.go's ranking (e.g. round 1's own 17-23/26-31 December
+// promotions, RG 91 entry 18 / RG 67-68) can silently flip this function's
+// verdict and rewrite generated data with no code change to this file at
+// all -- confirmed by direct measurement, not just reasoned about: running
+// this function's body against the branch-point engine versus the current
+// one over the six reference years flips the verdict on 50 dates across
+// exactly these 13 MM-DD values (12-17 through 12-23, 12-26 through
+// 12-31), no others. Any future temporal_ef.go rank change should re-check
+// this function's own boundary cases, not just its own tests.
+//
+// harvestDate keeps a saint's commemoration id rank ONLY if every reference
+// year in which it was seen returned true here -- a single false (an
+// unprivileged day, or the December exclusion) is enough to fall back to
+// RankCommemoration, the safe default (see St Blaise, whose own
+// commemoration id claims rank 4 and is still correctly overridden to
+// RankCommemoration, proving the id's rank is a candidate, not a verdict --
+// see the call site's own comment).
+func refYearExplainsAbsence(date time.Time) bool {
+ if date.Month() == time.December && date.Day() >= 17 && date.Day() <= 31 {
+ // RG 68(d)/(e) (26-31 Dec) and RG 91 entry 18 (17-23 Dec,
+ // precautionary): "die 29 decembris, fit commemoratio S. Thomae
+ // Episcopi et Mart.; die 31 decembris, fit commemoratio S.
+ // Silvestri I Papae et Conf." -- both named as a bare
+ // "Commemoratio", no class. See this function's own doc comment.
+ return false
+ }
+ 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 == "" {
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 is a BETTER rank signal than
+ // the DAY's own info.id (which names the rank of whatever
+ // propers are reused that day, not the commemorated saint's) --
+ // but "better" is not "always correct": St Blaise's own
+ // commemoration id is "sancti:02-03:4:r" (rank 4), and he is
+ // still, correctly, ruled RankCommemoration below, because he
+ // has no independent Mass at all in the 1960 books, not because
+ // his id's rank is wrong. The id's rank is a CANDIDATE value,
+ // trusted only when refYearExplainsAbsence's heuristic finds no
+ // counter-evidence across every reference year this slug is
+ // seen -- see that function's own doc comment for what the
+ // heuristic actually checks and its known limits.
+ 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,26 +594,62 @@ 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
+ }
+ }
+ }
+ 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 nil, comms
+ return obs, comms
}
func main() {
- // Existing Latin names to preserve (missalemeum has no Latin titles).
- la := map[string]string{}
+ // preservedNames holds every "name.<lang>" field already present in the
+ // existing file, keyed by slug then by the full field name -- EVERY
+ // language missalemeum does not itself supply (it has English titles
+ // only), not a hardcoded whitelist of one or two. A whitelist is
+ // exactly the bug this replaces: an earlier version of this generator
+ // preserved only name.la, and -- unnoticed, because the bootstrapped
+ // file has in fact never carried a name.la value at all, so that
+ // mechanism was silently inert from the start -- name.pl had no
+ // preservation mechanism whatsoever. A regeneration deleted all 322
+ // Polish names outright (measured: name.pl 322 -> 0), reaching
+ // mobile.Day(date, "ef", version, "pl") -- a shipped dlectio entry
+ // point -- on the app's next build, with `naming.CelebrationName`'s own
+ // name[lang] -> name.en fallback silently substituting English and no
+ // error anywhere. Generalising to every "name.*" key except name.en
+ // (English is always freshly regenerated from missalemeum, the whole
+ // point of this tool) means a third, fourth, or Nth language added to
+ // the file later survives a regeneration without this function ever
+ // needing to change again.
+ preservedNames := map[string]map[string]string{}
for slug, rc := range caldata.Tridentine().Cels {
- if v := rc.Fields["name.la"]; v != "" {
- la[slug] = v
+ for k, v := range rc.Fields {
+ if v == "" || !strings.HasPrefix(k, "name.") || k == "name.en" {
+ continue
+ }
+ if preservedNames[slug] == nil {
+ preservedNames[slug] = map[string]string{}
+ }
+ preservedNames[slug][k] = v
}
}
@@ -331,11 +683,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,17 +722,13 @@ 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)
}
}
es := make([]entry, 0, len(entries))
for _, e := range entries {
- if l := la[e.slug]; l != "" {
- e.la = l
- }
+ e.otherNames = preservedNames[e.slug]
es = append(es, e)
}
sort.Slice(es, func(i, j int) bool {
@@ -374,7 +745,9 @@ func main() {
b.WriteString("; by internal/calendar (temporalEF) and is NOT listed here.\n")
b.WriteString("; Ranks use the 1960 Code of Rubrics: class-1..class-4.\n")
b.WriteString("; Generated by scripts/gen-sanctoral-ef.go from missalemeum (Divinum Officium 1962\n")
- b.WriteString("; data). Latin names are hand-curated where present. See NOTICE.\n\n")
+ b.WriteString("; data). name.en is always regenerated fresh from missalemeum; every other\n")
+ b.WriteString("; name.<lang> (missalemeum supplies English only) is preserved verbatim from\n")
+ b.WriteString("; whatever this file already carried before regeneration. See NOTICE.\n\n")
b.WriteString("[layer]\nid = tridentine\nname = General Roman Calendar of 1962\ntype = universal\n")
for _, e := range es {
fmt.Fprintf(&b, "\n[%s]\ndate = %s\nrank = %s\ncolour = %s\n", e.slug, e.date, e.rank, e.colour)
@@ -382,8 +755,13 @@ func main() {
fmt.Fprintf(&b, "class = %s\n", e.class)
}
fmt.Fprintf(&b, "name.en = %s\n", e.en)
- if e.la != "" {
- fmt.Fprintf(&b, "name.la = %s\n", e.la)
+ otherKeys := make([]string, 0, len(e.otherNames))
+ for k := range e.otherNames {
+ otherKeys = append(otherKeys, k)
+ }
+ sort.Strings(otherKeys) // deterministic output regardless of map iteration order
+ for _, k := range otherKeys {
+ fmt.Fprintf(&b, "%s = %s\n", k, e.otherNames[k])
}
if e.first != "" {
fmt.Fprintf(&b, "reading.first = %s\n", e.first)