//go:build ignore // gen-sanctoral-ef generates the EF (Extraordinary Form, 1962) universal // sanctoral (internal/caldata/tridentine-calendar.ini) from missalemeum's // per-date proper API (built on Divinum Officium's 1962 data — the same oracle // lectio's trad view and the EF regression test use). // // For each fixed calendar date it takes the sanctoral office missalemeum // observes (info.id "sancti:MM-DD:RANK:COLOUR"), with its proper Epistle // (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 // 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: // // go run scripts/gen-sanctoral-ef.go // // Writes internal/caldata/tridentine-calendar.ini. package main import ( "encoding/json" "fmt" "net/http" "os" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/lukaszkasprzak/lectio/internal/caldata" "github.com/lukaszkasprzak/lectio/internal/calendar" ) const ua = "Mozilla/5.0 (lectio EF sanctoral generator)" // client with a hard timeout: missalemeum occasionally stalls a connection // under concurrent load, and a timeout-less client would hang a worker forever. var client = &http.Client{Timeout: 25 * time.Second} // reference years: tried in order per date until the saint is observed (not // occulted by a Sunday or higher feast). Six years guarantee every fixed date // falls on a weekday in at least one of them. var years = []int{2025, 2026, 2027, 2028, 2029, 2024} // tempFeastSkip: fixed-date Lord's feasts that missalemeum files under sancti: // but the EF temporal engine (temporalEF) already computes — excluded so they // are not duplicated in the sanctoral layer. var tempFeastSkip = map[string]bool{"01-01": true, "01-06": true, "12-25": true} var ( citeRe = regexp.MustCompile(`\*([^*]+)\*`) bookCommaRe = regexp.MustCompile(`^([^:]*?),\s+(\d+:)`) chapDotRe = regexp.MustCompile(`(\d+)\.\s+(\d)`) slugStripRe = regexp.MustCompile(`[^a-z0-9]+`) ) // cleanCite fixes the two missalemeum citation glitches (verbatim from // scripts/genlect.go): a stray comma after the book, and a European // "chapter. verse" separator. func cleanCite(s string) string { s = bookCommaRe.ReplaceAllString(strings.TrimSpace(s), "$1 $2") if !strings.Contains(s, ":") { s = chapDotRe.ReplaceAllString(s, "$1:$2") } return s } func slugify(title string) string { s := strings.ToLower(title) s = strings.NewReplacer( "ł", "l", "æ", "ae", "œ", "oe", "é", "e", "è", "e", "ô", "o", "ç", "c", "ï", "i", "ë", "e", "ü", "u", "ö", "o", "á", "a", "à", "a", ).Replace(s) s = slugStripRe.ReplaceAllString(s, "-") s = strings.Trim(s, "-") for _, p := range []string{"the-", "ss-", "st-", "s-"} { s = strings.TrimPrefix(s, p) } return s } var colourWord = map[byte]string{ 'w': "white", 'r': "red", 'g': "green", 'v': "violet", 'b': "black", 'p': "rose", } func colourOf(joined string) string { if joined == "" { return "white" } if c, ok := colourWord[joined[0]]; ok { return c } return "white" } var rankWord = map[int]calendar.Rank{ 1: calendar.RankClass1, 2: calendar.RankClass2, 3: calendar.RankClass3, 4: calendar.RankClass4, } func rankOf(n int) calendar.Rank { if r, ok := rankWord[n]; ok { return r } return calendar.RankClass4 } // 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 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 -- occurrence-behaviour evidence, see doc comment strings.Contains(l, "precious blood"), 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 the lord"): return "lord" } return "" } // isSaintTitle rejects the purely temporal offices that share the sancti: // namespace (octave days, ferias, Ember days) so only genuine celebrations are // harvested. Vigils of fixed feasts (Christmas, the Nativity of St John the // Baptist, Sts Peter & Paul, St Lawrence, the Assumption, All Saints, the // Immaculate Conception) ARE kept: they are proper liturgical days on a fixed // date. The movable vigils (Ascension, Pentecost) live in the tempora namespace // and are computed by the temporal engine, not harvested here. func isSaintTitle(t string) bool { l := strings.ToLower(t) for _, bad := range []string{"octave", "feria", "ember", "rogation", "sunday", "within the"} { if strings.Contains(l, bad) { return false } } return true } type mmInfo struct { ID string `json:"id"` Title string `json:"title"` Rank int `json:"rank"` Colors []string `json:"colors"` Commemorations []struct { ID string `json:"id"` Title string `json:"title"` } `json:"commemorations"` } type mmDay struct { Info mmInfo `json:"info"` Sections []struct { ID string `json:"id"` Body [][]string `json:"body"` } `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) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("status %d", resp.StatusCode) } var data []mmDay if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || len(data) == 0 { return nil, fmt.Errorf("decode/empty") } return &data[0], nil } // fetch retries transient failures (timeouts, 5xx) a few times before giving up. func fetch(date string) (*mmDay, error) { var err error for attempt := 0; attempt < 4; attempt++ { var d *mmDay if d, err = fetchOnce(date); err == nil { return d, nil } } return nil, err } type entry struct { slug, date, colour, class, en, first, gospel string rank calendar.Rank observed bool // has readings / reliable rank // otherNames holds every "name." 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. func idParts(id string) (calendar.Rank, string) { p := strings.Split(id, ":") if len(p) < 4 { return calendar.RankClass4, "white" } n, _ := strconv.Atoi(p[2]) 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 { continue } m := citeRe.FindStringSubmatch(s.Body[0][0]) if m == nil { continue } switch s.ID { case "Lectio": first = cleanCite(m[1]) case "Evangelium": gospel = cleanCite(m[1]) } } 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) { commTrack := map[string]*commTracker{} var obs *entry for _, y := range years { date := fmt.Sprintf("%04d-%s", y, mmdd) 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 == "" { continue } 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. // NOTE: don't skip merely because the temporal is class-1/2 in THIS // year — a real saint (e.g. Sts Peter & Paul) falling on a Sunday must // still be captured; another harvest year observes it on a weekday. if tempFeastSkip[mmdd] || strings.Contains(strings.ToLower(d.Info.Title), "christ the king") { // 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. // `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, "")) 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 := 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() { // preservedNames holds every "name." 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 { 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 } } // All fixed calendar dates (from a leap year so 02-29 is included). var dates []string for d := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC); d.Year() == 2028; d = d.AddDate(0, 0, 1) { dates = append(dates, d.Format("01-02")) } type res struct { obs *entry comms []entry } results := make([]res, len(dates)) var wg sync.WaitGroup sem := make(chan struct{}, 6) for i, mmdd := range dates { wg.Add(1) go func(i int, mmdd string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() obs, comms := harvestDate(mmdd) results[i] = res{obs, comms} fmt.Fprintf(os.Stderr, ".") }(i, mmdd) } wg.Wait() fmt.Fprintln(os.Stderr) entries := map[string]entry{} add := func(e entry) { if cur, ok := entries[e.slug]; ok { 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 } for _, r := range results { if r.obs != nil { add(*r.obs) } } for _, r := range results { // commemorations after, so observed offices win for _, c := range r.comms { add(c) } } es := make([]entry, 0, len(entries)) for _, e := range entries { e.otherNames = preservedNames[e.slug] es = append(es, e) } sort.Slice(es, func(i, j int) bool { if es[i].date != es[j].date { return es[i].date < es[j].date } return es[i].slug < es[j].slug }) var b strings.Builder b.WriteString("; General Roman Calendar of 1962 (Extraordinary Form) — universal sanctoral.\n") b.WriteString("; The temporal cycle (seasons, Sundays, Easter/Christmas/Epiphany, Ascension,\n") b.WriteString("; Pentecost, Trinity, Corpus Christi, Sacred Heart, Christ the King) is computed\n") 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). name.en is always regenerated fresh from missalemeum; every other\n") b.WriteString("; name. (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) if e.class != "" { fmt.Fprintf(&b, "class = %s\n", e.class) } fmt.Fprintf(&b, "name.en = %s\n", e.en) 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) } if e.gospel != "" { fmt.Fprintf(&b, "reading.gospel = %s\n", e.gospel) } } if err := os.WriteFile("internal/caldata/tridentine-calendar.ini", []byte(b.String()), 0o644); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } nObs := 0 for _, e := range es { if e.observed { nObs++ } } fmt.Fprintf(os.Stderr, "wrote %d EF sanctoral celebrations (%d observed w/ readings, %d commemoration-only)\n", len(es), nObs, len(es)-nObs) }