From 1f694889f767dedd84f60d8e0cbccf201c2d123f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 07:51:05 +0200 Subject: feat(ef): full 1962 universal sanctoral (16 -> 317 celebrations) Generate internal/caldata/tridentine-calendar.ini from missalemeum's per-date proper API (Divinum Officium 1962 data) via scripts/gen-sanctoral-ef.go: each fixed date's observed saint with rank, colour, English name and proper Epistle/Gospel citations, plus sancti commemorations. Multiple reference years are tried per date so a saint occulted by a Sunday in one year is still captured observed in another; saints never observed are ranked commemoration. Feasts of the Lord are marked class=lord. Latin names hand-curated (missalemeum has none). Adds TestTridentineLoads, credits missalemeum in NOTICE, bumps to 0.38.0. --- scripts/gen-sanctoral-ef.go | 402 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 scripts/gen-sanctoral-ef.go (limited to 'scripts/gen-sanctoral-ef.go') diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go new file mode 100644 index 0000000..f40fe34 --- /dev/null +++ b/scripts/gen-sanctoral-ef.go @@ -0,0 +1,402 @@ +//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 Latin names +// in the file are preserved (missalemeum has no Latin titles). +// +// 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 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. +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, "dedication of the archbasilica"), + strings.Contains(l, "of our holy savior"), + strings.Contains(l, "of our lord jesus"), + strings.HasSuffix(l, "of our lord"): + return "lord" + } + return "" +} + +// isSaintTitle rejects the temporal commemorations that share the sancti: +// namespace (octave days, vigils) so only genuine saints/feasts are harvested. +func isSaintTitle(t string) bool { + l := strings.ToLower(t) + for _, bad := range []string{"octave", "vigil", "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"` +} + +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, la, first, gospel string + rank calendar.Rank + observed bool // has readings / reliable rank +} + +// 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]) +} + +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 +} + +// 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. +func harvestDate(mmdd string) (*entry, []entry) { + var comms []entry + seenComm := map[string]bool{} + for _, y := range years { + date := fmt.Sprintf("%04d-%s", y, mmdd) + if _, err := time.Parse("2006-01-02", date); err != nil { + continue // e.g. 02-29 in a common year + } + d, err := fetch(date) + if err != nil { + continue + } + for _, c := range d.Info.Commemorations { + if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) { + continue + } + slug := slugify(c.Title) + if slug == "" || seenComm[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 strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) { + // 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. + return nil, comms + } + first, gospel := readingsFrom(d) + col := colourOf(strings.Join(d.Info.Colors, "")) + return &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 +} + +func main() { + // Existing Latin names to preserve (missalemeum has no Latin titles). + la := map[string]string{} + for slug, rc := range caldata.Tridentine().Cels { + if v := rc.Fields["name.la"]; v != "" { + la[slug] = 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.observed && !e.observed { + return // don't let a commemoration downgrade an observed office + } + if cur.observed && e.observed { + return // first observed year wins + } + } + 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 { + if _, ok := entries[c.slug]; !ok { + add(c) + } + } + } + + es := make([]entry, 0, len(entries)) + for _, e := range entries { + if l := la[e.slug]; l != "" { + e.la = l + } + 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). Latin names are hand-curated where present. 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) + if e.la != "" { + fmt.Fprintf(&b, "name.la = %s\n", e.la) + } + 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) +} -- cgit v1.3