//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 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"` } 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) }