//go:build ignore // gen-sanctoral generates the OF (Ordinary Form) universal sanctoral // (internal/caldata/roman-calendar.ini) from the authoritative General Roman // Calendar published by calapi.inadiutorium.cz. For each fixed date it takes // the saint/Marian/Lord celebrations (memorial/optional-memorial/feast/ // solemnity) that the temporal engine does NOT already compute, with: // - English name + rank + colour from calapi general-en, // - Latin name from calapi general-la, // - Polish name from niedziela.pl (DayInfo, obligatory celebrations). // // Multiple reference years are unioned so a saint whose date is a Sunday in one // year is still captured from another. One-time; requires network + curl-ish UA. // // go run scripts/gen-sanctoral.go // // Writes internal/caldata/roman-calendar.ini. package main import ( "encoding/json" "fmt" "net/http" "os" "regexp" "sort" "strings" "time" "github.com/lukaszkasprzak/lectio/internal/calendar" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/readings" ) const ua = "Mozilla/5.0 (lectio sanctoral generator)" type calDay struct { Date string `json:"date"` Celebrations []struct { Title string `json:"title"` Colour string `json:"colour"` Rank string `json:"rank"` RankNum float64 `json:"rank_num"` } `json:"celebrations"` } func fetchMonth(cal string, y, m int) ([]calDay, error) { url := fmt.Sprintf("http://calapi.inadiutorium.cz/api/v0/en/calendars/%s/%d/%d", cal, y, m) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", ua) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var days []calDay if err := json.NewDecoder(resp.Body).Decode(&days); err != nil { return nil, err } return days, nil } var ( // "commemoration" is calapi's rank for a saint reduced to a commemoration by a // privileged season (Lent, late Advent, the Christmas octave). Such a saint is // never the observed day, so it maps to an optional memorial for lectio; its // true (higher) rank is recovered from a year where its date is an ordinary // weekday (see rankBand + the highest-rank-wins merge below). rankMap = map[string]calendar.Rank{"solemnity": calendar.RankSolemnity, "feast": calendar.RankFeast, "memorial": calendar.RankMemorial, "optional memorial": calendar.RankOptional, "commemoration": calendar.RankOptional} slugStripRe = regexp.MustCompile(`[^a-z0-9]+`) plPrefixRe = regexp.MustCompile(`^(?:Uroczystość|Święto|Wspomnienie(?: obowiązkowe)?)\s+`) ) // rankBand orders OF ranks for the highest-rank-wins merge (higher = higher rank). func rankBand(r calendar.Rank) int { switch r { case calendar.RankSolemnity: return 5 case calendar.RankFeast: return 4 case calendar.RankMemorial: return 3 case calendar.RankOptional: return 2 default: return 0 } } func slugify(title string) string { s := strings.ToLower(title) s = strings.NewReplacer("ł", "l", "æ", "ae", "é", "e", "è", "e", "ô", "o", "ç", "c").Replace(s) s = slugStripRe.ReplaceAllString(s, "-") s = strings.Trim(s, "-") s = strings.TrimPrefix(s, "saint-") s = strings.TrimPrefix(s, "st-") return s } // classOf infers the celebration class from its English title (precedence hint). // Feasts of the Lord (class lord) outrank Ordinary Sundays; the BVM class carries // Marian-commemoration precedence. Order matters: the Lord tests run before the // saint default; "holy cross" (the feast) is distinguished from "of the cross" // (saints like John of the Cross), which stay saints. func classOf(en string) string { l := strings.ToLower(en) switch { case strings.Contains(l, "holy cross"), strings.Contains(l, "transfiguration of the lord"), strings.Contains(l, "presentation of the lord"), strings.Contains(l, "holy name of jesus"), strings.Contains(l, "sacred heart"), strings.Contains(l, "holy trinity"), strings.Contains(l, "body and blood"), strings.Contains(l, "dedication of the lateran"), strings.HasSuffix(l, "of the lord"), strings.Contains(l, "of the lord,"): return "lord" case strings.Contains(l, "blessed virgin mary"), strings.Contains(l, "our lady"), strings.Contains(l, "of mary"), strings.Contains(l, "queenship"), strings.Contains(l, "immaculate heart"), strings.Contains(l, "the annunciation"): return "bvm" default: return "saint" } } type entry struct { slug, date, rank, colour, class, en, la, pl string rankNum float64 } func main() { sel := calendar.DefaultSelection() sel.Form = "new" cfg := config.Default() cfg.Lectionary = "new" entries := map[string]entry{} // slug -> entry (first year wins per slug) // Genuinely MOVABLE celebrations tied to the temporal cycle (no fixed date): // a fixed-date sanctoral cannot represent them; they need temporal-engine // support. Skipped here (tracked as a known gap). movableSkip := map[string]bool{ "mary-mother-of-the-church": true, // Monday after Pentecost "the-immaculate-heart-of-mary": true, // Saturday after the Sacred Heart "the-memorial-of-the-blessed-virgin-mary-on-saturday": true, // any free Saturday } // Reference years: enough spread that every fixed date falls on an ordinary // weekday (not a Sunday, not inside a privileged season) in at least one year, // so each saint is seen with its true rank. Start at 2025 (past -> niedziela // gives pl names; also the date of a transferred feast is taken from the first // year it is observed, and 2025 observes Joseph/Annunciation on their proper // fixed dates). for _, y := range []int{2025, 2026, 2027, 2028, 2029} { for m := 1; m <= 12; m++ { en, err := fetchMonth("general-en", y, m) if err != nil { fmt.Fprintf(os.Stderr, "en %d/%d: %v\n", y, m, err) continue } la, _ := fetchMonth("general-la", y, m) laByDate := map[string][]string{} for _, d := range la { for _, c := range d.Celebrations { laByDate[d.Date] = append(laByDate[d.Date], c.Title) } } for _, d := range en { t, _ := time.Parse("2006-01-02", d.Date) // Skip dates the temporal engine owns (its own solemnities/feasts: // Christmas, Easter cycle, etc.). Sanctoral dates are ferial (or a // plain Sunday) in the pure temporal. temp := calendar.Compute(t, sel, nil) // Skip dates the temporal engine owns: Sundays and its own // solemnities/feasts (Christmas & Easter cycles, Christ the King, // Trinity, Corpus Christi, …). Sanctoral saints are captured on their // weekday (ferial) occurrences -- and since a fixed date lands on a // weekday in most years, one reference year suffices per saint. if temp.Observed.Rank == calendar.RankSolemnity || temp.Observed.Rank == calendar.RankFeast { continue } // Polish name for the observed (obligatory) celebration, 2025 only. plName := "" if y == 2025 { if _, info, e := readings.Load(cfg, readings.Options{Date: d.Date, All: false}); e == nil { plName = strings.TrimSpace(plPrefixRe.ReplaceAllString(info.Name, "")) } } for i, c := range d.Celebrations { rk, ok := rankMap[c.Rank] if !ok { continue // "ferial" or unknown -> not a sanctoral saint } slug := slugify(c.Title) if slug == "" || movableSkip[slug] { continue } // Highest-rank-wins: a saint seen as a commemoration in a // privileged season one year and as its full rank on an ordinary // weekday another year keeps the higher rank. Date/name/pl are // kept from the first (proper-date) occurrence; rank/colour/class // are upgraded. if cur, seen := entries[slug]; seen { if rankBand(rk) > rankBand(calendar.Rank(cur.rank)) { cur.rank = string(rk) cur.colour = c.Colour cur.class = classOf(c.Title) cur.rankNum = c.RankNum entries[slug] = cur } continue } laTitle := "" if l := laByDate[d.Date]; i < len(l) { laTitle = l[i] } pl := "" if i == 0 { // the observed celebration niedziela reports pl = plName } entries[slug] = entry{ slug: slug, date: t.Format("01-02"), rank: string(rk), colour: c.Colour, class: classOf(c.Title), en: c.Title, la: laTitle, pl: pl, rankNum: c.RankNum, } } } fmt.Fprintf(os.Stderr, "%d/%02d done (%d entries so far)\n", y, m, len(entries)) } } es := make([]entry, 0, len(entries)) for _, e := range entries { 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].rankNum < es[j].rankNum // higher rank (lower num) first }) var b strings.Builder b.WriteString("; General Roman Calendar (Ordinary Form) — universal sanctoral.\n") b.WriteString("; The temporal cycle (Sundays, seasons, Easter/Christmas cycles, Christ the King,\n") b.WriteString("; Baptism, Holy Family, Trinity, Corpus Christi, Sacred Heart) is computed by\n") b.WriteString("; internal/calendar and is NOT listed here.\n") b.WriteString("; Generated by scripts/gen-sanctoral.go from calapi.inadiutorium.cz (General Roman\n") b.WriteString("; Calendar; en+la names) and niedziela.pl (pl names). See NOTICE.\n\n") b.WriteString("[layer]\nid = universal\nname = General Roman Calendar\ntype = universal\n") for _, e := range es { fmt.Fprintf(&b, "\n[%s]\ndate = %s\nrank = %s\n", e.slug, e.date, e.rank) if e.class != "" { fmt.Fprintf(&b, "class = %s\n", e.class) } fmt.Fprintf(&b, "colour = %s\n", e.colour) fmt.Fprintf(&b, "name.en = %s\n", e.en) if e.pl != "" { fmt.Fprintf(&b, "name.pl = %s\n", e.pl) } if e.la != "" { fmt.Fprintf(&b, "name.la = %s\n", e.la) } } if err := os.WriteFile("internal/caldata/roman-calendar.ini", []byte(b.String()), 0o644); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Fprintf(os.Stderr, "\nwrote %d sanctoral celebrations\n", len(es)) }