diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 00:47:16 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 00:47:16 +0200 |
| commit | e829b48b08c683d9dcef5e4b9502c1c42bc895f1 (patch) | |
| tree | 63afd5857a2a98e476a6d7e8fc10b25056b905eb /scripts/gen-sanctoral.go | |
| parent | 6c320f88cc5659233001894614e39265ca4e6363 (diff) | |
| download | lectio-e829b48b08c683d9dcef5e4b9502c1c42bc895f1.tar.gz lectio-e829b48b08c683d9dcef5e4b9502c1c42bc895f1.zip | |
feat(of): full sanctoral calendar + weekday/memorial readings
Populate the OF General Roman Calendar sanctoral and extend the OF readings to
weekdays and sanctoral days.
Sanctoral calendar (roman-calendar.ini: 29 -> 202 celebrations):
- scripts/gen-sanctoral.go generates it from calapi.inadiutorium.cz (authoritative
General Roman Calendar) -- English name/rank/colour from general-en, Latin from
general-la, Polish from niedziela DayInfo (obligatory celebrations). Fixed dates
the temporal engine already computes are excluded. VALIDATED: my engine's
observed rank agrees with calapi on all 86 obligatory sanctoral days of 2026
(0 mismatches).
Precedence fix (precedence.go): an optional memorial no longer displaces the
weekday as the DEFAULT observed celebration (it sorts below the ferial, shown as
an option) -- matching the General Roman Calendar / calapi.
Readings (of-lectionary.ini: 254 -> 1018 entries) via genlect-of.go:
- Weekday (ferial) 2-year cycle I/II, keyed <ferial-slug>-<WeekdayCycle>, harvested
from niedziela over 2020-2025 (multiple years per cycle; retry-on-failure so a
glitch year never poisons a key; only TRUE ferials + optional-memorial days,
where niedziela shows the ferial).
- Obligatory memorials keyed by their OWN slug (niedziela shows their proper, e.g.
Barnabas -> Acts 11, or the ferial for memorials without a proper) -- never
miskeyed to a ferial position.
- caldata.Readings: OF resolves slug+SundayCycle | slug+WeekdayCycle, else a
memorial falls back to the day's ferial readings.
- Source-glitch normalisation (1J->1 J, PnP->Pnp).
books.ini: canonical names "Song of Solomon" and "The Acts" added as their own
[en] forms (ToEnglishRef emits the canonical; it must resolve). Fixes ~32
cross-chapter Song-of-Songs/Acts citations.
Coverage: 359/365 days of 2026 render (98.4%); 6 residual ferial positions never a
true-ferial in 2020-2025. EF sanctoral and OF psalm renumbering still pending.
Diffstat (limited to 'scripts/gen-sanctoral.go')
| -rw-r--r-- | scripts/gen-sanctoral.go | 209 |
1 files changed, 209 insertions, 0 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)) +} |
