diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/gen-sanctoral.go | 209 | ||||
| -rw-r--r-- | scripts/genlect-of.go | 103 |
2 files changed, 283 insertions, 29 deletions
diff --git a/scripts/gen-sanctoral.go b/scripts/gen-sanctoral.go new file mode 100644 index 0000000..b179b67 --- /dev/null +++ b/scripts/gen-sanctoral.go @@ -0,0 +1,209 @@ +//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). +func classOf(en string) string { + l := strings.ToLower(en) + switch { + case strings.Contains(l, "blessed virgin mary"), strings.Contains(l, "our lady"), strings.Contains(l, "of mary"): + return "bvm" + case strings.Contains(l, "the lord"), strings.Contains(l, "of the cross"), strings.Contains(l, "transfiguration"), strings.Contains(l, "holy trinity"): + return "lord" + 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) + + // 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) + if temp.Observed.Rank == calendar.RankSolemnity && t.Weekday() != time.Sunday { + continue + } + if 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 == "" { + 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)) +} diff --git a/scripts/genlect-of.go b/scripts/genlect-of.go index 6126dea..00d0992 100644 --- a/scripts/genlect-of.go +++ b/scripts/genlect-of.go @@ -1,9 +1,11 @@ //go:build ignore -// genlect-of generates the OF (Ordinary Form) Sunday/solemnity temporal -// lectionary from niedziela.pl (via internal/readings), keyed by lectio's -// computed <Observed.Slug>-<SundayCycle>. One-time; requires network. Run -// from the repo root: +// genlect-of generates the OF (Ordinary Form) temporal lectionary from +// niedziela.pl (via internal/readings): Sundays & solemnities keyed by +// <Observed.Slug>-<SundayCycle> (A/B/C), ferial weekdays by <ferial-slug>- +// <WeekdayCycle> (I/II). Only TRUE ferials are harvested for the weekday table; +// memorial days are skipped (their readings come from the ferial fallback at +// runtime). One-time; requires network. Run from the repo root: // // go run scripts/genlect-of.go // @@ -47,6 +49,27 @@ func cleanCite(s string) string { return s } +// numBookSpaceRe restores the space niedziela sometimes drops in a numbered +// book sigil ("1J 3,11" -> "1 J 3,11"; "2Kor" -> "2 Kor") so ToEnglishRef's +// Polish table (keyed "1 J", "2 Kor", …) matches. Applied to the raw Polish +// citation BEFORE ToEnglishRef. +var numBookSpaceRe = regexp.MustCompile(`^(\d)([^\d\s])`) + +// sourceGlitches are exact niedziela sigla-casing glitches that don't match the +// ToEnglishRef Polish table (which is Title-case). E.g. "PnP" for Song of Songs. +var sourceGlitches = map[string]string{"PnP": "Pnp"} + +// fixSourceCite normalises a raw niedziela citation before ToEnglishRef. +func fixSourceCite(s string) string { + s = numBookSpaceRe.ReplaceAllString(strings.TrimSpace(s), "$1 $2") + for bad, good := range sourceGlitches { + if strings.HasPrefix(s, bad+" ") { + s = good + s[len(bad):] + } + } + return s +} + // partIDToPart maps a niedziela.pl (liturgy.Section) PartID to our lectionary // field name. Anything absent (e.g. "aklamacja") is not a reading and is // ignored. @@ -67,35 +90,49 @@ func main() { lect := map[string]map[string]string{} // key -> part -> citation seen := map[string]bool{} - var missing []string // date+key kept but missing first/gospel + missing := map[string]string{} // key -> last failure reason; cleared on success - // Harvest the PAST cycle-equivalent years: 2023=A, 2024=B, 2025=C. niedziela - // only publishes ~6 weeks ahead, so future years (2026-2027) are unavailable; - // the 3-year cycle repeats, so a liturgical position's cycle-A readings are the - // same whether they fall in 2023 or 2026. All three years are past and archived. - start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + // niedziela only publishes ~6 weeks ahead, so future years are unavailable; + // the cycles repeat, so a liturgical position's readings are identical + // whichever civil year they fall in. Harvest 2020-2025 -- TWO+ years per + // cycle (Sunday A:2020,2023 B:2021,2024 C:2022,2025; weekday I:2021,2023,2025 + // II:2020,2022,2024) so a position displaced by a saint/feast in one year is + // still captured in another. A key is stored from the FIRST year it resolves; + // a year where it fails does not block a later retry (seen is set on success). + start := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC) for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { - day := calendar.Compute(d, sel, layers) - keep := d.Weekday() == time.Sunday || - day.Observed.Rank == calendar.RankSolemnity || - day.Observed.Rank == calendar.RankFeast - if !keep { - continue - } + full := calendar.Compute(d, sel, layers) + sunOrSol := d.Weekday() == time.Sunday || + full.Observed.Rank == calendar.RankSolemnity || + full.Observed.Rank == calendar.RankFeast - key := day.Observed.Slug + "-" + day.SundayCycle + var key string + if sunOrSol { + key = full.Observed.Slug + "-" + full.SundayCycle + } else if full.Observed.Rank == calendar.RankFerial && full.Observed.Layer == "temporal" { + // A true ferial (incl. optional-memorial days, where the ferial is the + // observed default): niedziela shows the ferial readings. + key = full.Observed.Slug + "-" + full.WeekdayCycle + } else if full.Observed.Rank == calendar.RankMemorial { + // An obligatory memorial: niedziela shows its PROPER readings (e.g. St + // Barnabas, Acts 11) or, for memorials without proper readings, the + // ferial. Either way it is keyed by the memorial's OWN slug, so it is + // correctly attributed and never miskeyed to a ferial position. + key = full.Observed.Slug + "-" + full.WeekdayCycle + } else { + continue // (unreachable: optional memorials are ferial-observed) + } if seen[key] { - continue + continue // already stored from an earlier year } - seen[key] = true dateStr := d.Format("2006-01-02") secs, _, err := readings.Load(cfg, readings.Options{Date: dateStr, All: true}) if err != nil { fmt.Fprintf(os.Stderr, "%s %-32s ERROR readings.Load: %v\n", dateStr, key, err) - missing = append(missing, fmt.Sprintf("%s %s (readings.Load error: %v)", dateStr, key, err)) + missing[key] = fmt.Sprintf("%s (readings.Load error: %v)", dateStr, err) continue } @@ -109,7 +146,7 @@ func main() { if part == "psalm" { system = "drb" } - eng, err := bible.ToEnglishRef(s.Citation, system) + eng, err := bible.ToEnglishRef(fixSourceCite(s.Citation), system) if err != nil { fmt.Fprintf(os.Stderr, "%s %-32s part=%-6s citation=%q: ToEnglishRef error: %v\n", dateStr, key, part, s.Citation, err) @@ -119,11 +156,13 @@ func main() { } if parts["first"] == "" || parts["gospel"] == "" { - missing = append(missing, fmt.Sprintf("%s %s (first=%q gospel=%q)", dateStr, key, parts["first"], parts["gospel"])) + missing[key] = fmt.Sprintf("%s (first=%q gospel=%q)", dateStr, parts["first"], parts["gospel"]) continue // require at least first+gospel to store the entry } lect[key] = parts + seen[key] = true // store from the first year it resolves; stop retrying + delete(missing, key) // a later year succeeded; not actually missing fmt.Fprintf(os.Stderr, "%s %-32s first=%-22s psalm=%-22s second=%-22s gospel=%s\n", dateStr, key, parts["first"], parts["psalm"], parts["second"], parts["gospel"]) } @@ -135,9 +174,10 @@ func main() { sort.Strings(keys) var b strings.Builder - b.WriteString("; OF (Ordinary Form) Sunday & solemnity lectionary, keyed by\n") - b.WriteString("; <computed-temporal-slug>-<SundayCycle>. Citations English-canonical.\n") - b.WriteString("; Generated from niedziela.pl by scripts/genlect-of.go (2025-2027).\n") + b.WriteString("; OF (Ordinary Form) temporal lectionary, keyed by <computed-temporal-slug>-<cycle>:\n") + b.WriteString("; Sundays & solemnities use the Sunday cycle (A/B/C), ferial weekdays the weekday\n") + b.WriteString("; cycle (I/II). Citations English-canonical.\n") + b.WriteString("; Generated from niedziela.pl by scripts/genlect-of.go (harvest 2020-2025).\n") for _, k := range keys { v := lect[k] fmt.Fprintf(&b, "\n[%s]\n", k) @@ -154,9 +194,14 @@ func main() { fmt.Fprintf(os.Stderr, "\nwrote %d OF temporal entries\n", len(lect)) if len(missing) > 0 { - fmt.Fprintf(os.Stderr, "%d kept days missing first/gospel (not stored):\n", len(missing)) - for _, m := range missing { - fmt.Fprintln(os.Stderr, m) + mk := make([]string, 0, len(missing)) + for k := range missing { + mk = append(mk, k) + } + sort.Strings(mk) + fmt.Fprintf(os.Stderr, "%d keys NEVER resolved in any harvest year:\n", len(missing)) + for _, k := range mk { + fmt.Fprintf(os.Stderr, " %-34s %s\n", k, missing[k]) } } } |
