aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 00:47:16 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-28 00:47:16 +0200
commite829b48b08c683d9dcef5e4b9502c1c42bc895f1 (patch)
tree63afd5857a2a98e476a6d7e8fc10b25056b905eb /scripts
parent6c320f88cc5659233001894614e39265ca4e6363 (diff)
downloadlectio-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')
-rw-r--r--scripts/gen-sanctoral.go209
-rw-r--r--scripts/genlect-of.go103
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])
}
}
}