//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 ( rankMap = map[string]calendar.Rank{"solemnity": calendar.RankSolemnity, "feast": calendar.RankFeast, "memorial": calendar.RankMemorial, "optional memorial": calendar.RankOptional} slugStripRe = regexp.MustCompile(`[^a-z0-9]+`) plPrefixRe = regexp.MustCompile(`^(?:Uroczystość|Święto|Wspomnienie(?: obowiązkowe)?)\s+`) ) 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: cover every weekday so a saint whose date is a Sunday in // one year is captured in another. 2025 is past -> niedziela gives pl names. for _, y := range []int{2025, 2026, 2027} { 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 } if _, seen := entries[slug]; seen { 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)) }