summaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral-ef.go
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/gen-sanctoral-ef.go')
-rw-r--r--scripts/gen-sanctoral-ef.go402
1 files changed, 402 insertions, 0 deletions
diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go
new file mode 100644
index 0000000..f40fe34
--- /dev/null
+++ b/scripts/gen-sanctoral-ef.go
@@ -0,0 +1,402 @@
+//go:build ignore
+
+// gen-sanctoral-ef generates the EF (Extraordinary Form, 1962) universal
+// sanctoral (internal/caldata/tridentine-calendar.ini) from missalemeum's
+// per-date proper API (built on Divinum Officium's 1962 data — the same oracle
+// lectio's trad view and the EF regression test use).
+//
+// For each fixed calendar date it takes the sanctoral office missalemeum
+// observes (info.id "sancti:MM-DD:RANK:COLOUR"), with its proper Epistle
+// (Lectio) and Gospel (Evangelium) citations, plus any sancti co-celebrations
+// listed as commemorations. Several reference years are tried per date so a
+// saint whose date is a Sunday (or under a higher feast) in one year is still
+// captured, observed with its own readings, from another. Existing Latin names
+// in the file are preserved (missalemeum has no Latin titles).
+//
+// One-time; requires network. Run from the repo root:
+//
+// go run scripts/gen-sanctoral-ef.go
+//
+// Writes internal/caldata/tridentine-calendar.ini.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+)
+
+const ua = "Mozilla/5.0 (lectio EF sanctoral generator)"
+
+// client with a hard timeout: missalemeum occasionally stalls a connection
+// under concurrent load, and a timeout-less client would hang a worker forever.
+var client = &http.Client{Timeout: 25 * time.Second}
+
+// reference years: tried in order per date until the saint is observed (not
+// occulted by a Sunday or higher feast). Six years guarantee every fixed date
+// falls on a weekday in at least one of them.
+var years = []int{2025, 2026, 2027, 2028, 2029, 2024}
+
+// tempFeastSkip: fixed-date Lord's feasts that missalemeum files under sancti:
+// but the EF temporal engine (temporalEF) already computes — excluded so they
+// are not duplicated in the sanctoral layer.
+var tempFeastSkip = map[string]bool{"01-01": true, "01-06": true, "12-25": true}
+
+var (
+ citeRe = regexp.MustCompile(`\*([^*]+)\*`)
+ bookCommaRe = regexp.MustCompile(`^([^:]*?),\s+(\d+:)`)
+ chapDotRe = regexp.MustCompile(`(\d+)\.\s+(\d)`)
+ slugStripRe = regexp.MustCompile(`[^a-z0-9]+`)
+)
+
+// cleanCite fixes the two missalemeum citation glitches (verbatim from
+// scripts/genlect.go): a stray comma after the book, and a European
+// "chapter. verse" separator.
+func cleanCite(s string) string {
+ s = bookCommaRe.ReplaceAllString(strings.TrimSpace(s), "$1 $2")
+ if !strings.Contains(s, ":") {
+ s = chapDotRe.ReplaceAllString(s, "$1:$2")
+ }
+ return s
+}
+
+func slugify(title string) string {
+ s := strings.ToLower(title)
+ s = strings.NewReplacer(
+ "ł", "l", "æ", "ae", "œ", "oe", "é", "e", "è", "e", "ô", "o", "ç", "c",
+ "ï", "i", "ë", "e", "ü", "u", "ö", "o", "á", "a", "à", "a",
+ ).Replace(s)
+ s = slugStripRe.ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+ for _, p := range []string{"the-", "ss-", "st-", "s-"} {
+ s = strings.TrimPrefix(s, p)
+ }
+ return s
+}
+
+var colourWord = map[byte]string{
+ 'w': "white", 'r': "red", 'g': "green",
+ 'v': "violet", 'b': "black", 'p': "rose",
+}
+
+func colourOf(joined string) string {
+ if joined == "" {
+ return "white"
+ }
+ if c, ok := colourWord[joined[0]]; ok {
+ return c
+ }
+ return "white"
+}
+
+var rankWord = map[int]calendar.Rank{
+ 1: calendar.RankClass1, 2: calendar.RankClass2,
+ 3: calendar.RankClass3, 4: calendar.RankClass4,
+}
+
+func rankOf(n int) calendar.Rank {
+ if r, ok := rankWord[n]; ok {
+ return r
+ }
+ return calendar.RankClass4
+}
+
+// classOf marks the feasts of the Lord (class lord). A II class feast of the
+// Lord takes the place of a Sunday of the same class (1960 occurrence rules) —
+// e.g. the Purification (Presentation, Feb 2), the Exaltation of the Holy Cross
+// (Sep 14), the Dedication of the Lateran (Nov 9). Saint/BVM feasts of the same
+// class are only commemorated on a Sunday, so they need no marker.
+func classOf(en string) string {
+ l := strings.ToLower(en)
+ switch {
+ case strings.Contains(l, "holy cross"), // Exaltation / Finding of the Holy Cross
+ strings.Contains(l, "transfiguration"),
+ strings.Contains(l, "purification"), // the Presentation of the Lord
+ strings.Contains(l, "precious blood"),
+ strings.Contains(l, "holy name"),
+ strings.Contains(l, "dedication of the archbasilica"),
+ strings.Contains(l, "of our holy savior"),
+ strings.Contains(l, "of our lord jesus"),
+ strings.HasSuffix(l, "of our lord"):
+ return "lord"
+ }
+ return ""
+}
+
+// isSaintTitle rejects the temporal commemorations that share the sancti:
+// namespace (octave days, vigils) so only genuine saints/feasts are harvested.
+func isSaintTitle(t string) bool {
+ l := strings.ToLower(t)
+ for _, bad := range []string{"octave", "vigil", "feria", "ember", "rogation", "sunday", "within the"} {
+ if strings.Contains(l, bad) {
+ return false
+ }
+ }
+ return true
+}
+
+type mmInfo struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Rank int `json:"rank"`
+ Colors []string `json:"colors"`
+ Commemorations []struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ } `json:"commemorations"`
+}
+
+type mmDay struct {
+ Info mmInfo `json:"info"`
+ Sections []struct {
+ ID string `json:"id"`
+ Body [][]string `json:"body"`
+ } `json:"sections"`
+}
+
+func fetchOnce(date string) (*mmDay, error) {
+ req, _ := http.NewRequest("GET", "https://www.missalemeum.com/en/api/v5/proper/"+date, nil)
+ req.Header.Set("User-Agent", ua)
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("status %d", resp.StatusCode)
+ }
+ var data []mmDay
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || len(data) == 0 {
+ return nil, fmt.Errorf("decode/empty")
+ }
+ return &data[0], nil
+}
+
+// fetch retries transient failures (timeouts, 5xx) a few times before giving up.
+func fetch(date string) (*mmDay, error) {
+ var err error
+ for attempt := 0; attempt < 4; attempt++ {
+ var d *mmDay
+ if d, err = fetchOnce(date); err == nil {
+ return d, nil
+ }
+ }
+ return nil, err
+}
+
+type entry struct {
+ slug, date, colour, class, en, la, first, gospel string
+ rank calendar.Rank
+ observed bool // has readings / reliable rank
+}
+
+// idParts splits "sancti:MM-DD[sfx]:RANK:COLOUR" into rank word and colour word.
+func idParts(id string) (calendar.Rank, string) {
+ p := strings.Split(id, ":")
+ if len(p) < 4 {
+ return calendar.RankClass4, "white"
+ }
+ n, _ := strconv.Atoi(p[2])
+ return rankOf(n), colourOf(p[3])
+}
+
+func readingsFrom(d *mmDay) (first, gospel string) {
+ for _, s := range d.Sections {
+ if len(s.Body) == 0 || len(s.Body[0]) == 0 {
+ continue
+ }
+ m := citeRe.FindStringSubmatch(s.Body[0][0])
+ if m == nil {
+ continue
+ }
+ switch s.ID {
+ case "Lectio":
+ first = cleanCite(m[1])
+ case "Evangelium":
+ gospel = cleanCite(m[1])
+ }
+ }
+ return
+}
+
+// harvestDate returns the observed sanctoral office for a fixed MM-DD (nil if
+// the date is always a feria/temporal) plus any sancti commemorations seen.
+func harvestDate(mmdd string) (*entry, []entry) {
+ var comms []entry
+ seenComm := map[string]bool{}
+ for _, y := range years {
+ date := fmt.Sprintf("%04d-%s", y, mmdd)
+ if _, err := time.Parse("2006-01-02", date); err != nil {
+ continue // e.g. 02-29 in a common year
+ }
+ d, err := fetch(date)
+ if err != nil {
+ continue
+ }
+ for _, c := range d.Info.Commemorations {
+ if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) {
+ continue
+ }
+ slug := slugify(c.Title)
+ if slug == "" || seenComm[slug] {
+ continue
+ }
+ seenComm[slug] = true
+ _, col := idParts(c.ID)
+ // A saint never OBSERVED in any harvest year — only ever commemorated —
+ // is a commemoration in the 1962 universal calendar: it does NOT have
+ // its own Mass and yields to the ferial office (which is celebrated with
+ // the saint commemorated). Rank it RankCommemoration, not the id's class
+ // (missalemeum reuses class-3/4 for these), so a genuine IV class feast
+ // still outranks the feria while a commemoration does not. If the same
+ // slug is observed in another year, that observed entry supersedes this.
+ comms = append(comms, entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: calendar.RankCommemoration})
+ }
+ if strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) {
+ // A few Lord's feasts live in missalemeum's sancti namespace but the
+ // EF temporal engine already computes them (Nativity, Circumcision,
+ // Epiphany); exclude them so they aren't duplicated in the sanctoral.
+ // NOTE: don't skip merely because the temporal is class-1/2 in THIS
+ // year — a real saint (e.g. Sts Peter & Paul) falling on a Sunday must
+ // still be captured; another harvest year observes it on a weekday.
+ if tempFeastSkip[mmdd] || strings.Contains(strings.ToLower(d.Info.Title), "christ the king") {
+ // Christ the King is movable (last Sunday of October) and computed
+ // by the temporal engine; missalemeum files it under sancti, so it
+ // would otherwise leak into the sanctoral at a spurious fixed date.
+ return nil, comms
+ }
+ first, gospel := readingsFrom(d)
+ col := colourOf(strings.Join(d.Info.Colors, ""))
+ return &entry{
+ slug: slugify(d.Info.Title), date: mmdd, colour: col, class: classOf(d.Info.Title),
+ en: d.Info.Title, first: first, gospel: gospel,
+ rank: rankOf(d.Info.Rank), observed: true,
+ }, comms
+ }
+ }
+ return nil, comms
+}
+
+func main() {
+ // Existing Latin names to preserve (missalemeum has no Latin titles).
+ la := map[string]string{}
+ for slug, rc := range caldata.Tridentine().Cels {
+ if v := rc.Fields["name.la"]; v != "" {
+ la[slug] = v
+ }
+ }
+
+ // All fixed calendar dates (from a leap year so 02-29 is included).
+ var dates []string
+ for d := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC); d.Year() == 2028; d = d.AddDate(0, 0, 1) {
+ dates = append(dates, d.Format("01-02"))
+ }
+
+ type res struct {
+ obs *entry
+ comms []entry
+ }
+ results := make([]res, len(dates))
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, 6)
+ for i, mmdd := range dates {
+ wg.Add(1)
+ go func(i int, mmdd string) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ obs, comms := harvestDate(mmdd)
+ results[i] = res{obs, comms}
+ fmt.Fprintf(os.Stderr, ".")
+ }(i, mmdd)
+ }
+ wg.Wait()
+ fmt.Fprintln(os.Stderr)
+
+ entries := map[string]entry{}
+ add := func(e entry) {
+ if cur, ok := entries[e.slug]; ok {
+ if cur.observed && !e.observed {
+ return // don't let a commemoration downgrade an observed office
+ }
+ if cur.observed && e.observed {
+ return // first observed year wins
+ }
+ }
+ entries[e.slug] = e
+ }
+ for _, r := range results {
+ if r.obs != nil {
+ add(*r.obs)
+ }
+ }
+ for _, r := range results { // commemorations after, so observed offices win
+ for _, c := range r.comms {
+ if _, ok := entries[c.slug]; !ok {
+ add(c)
+ }
+ }
+ }
+
+ es := make([]entry, 0, len(entries))
+ for _, e := range entries {
+ if l := la[e.slug]; l != "" {
+ e.la = l
+ }
+ 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].slug < es[j].slug
+ })
+
+ var b strings.Builder
+ b.WriteString("; General Roman Calendar of 1962 (Extraordinary Form) — universal sanctoral.\n")
+ b.WriteString("; The temporal cycle (seasons, Sundays, Easter/Christmas/Epiphany, Ascension,\n")
+ b.WriteString("; Pentecost, Trinity, Corpus Christi, Sacred Heart, Christ the King) is computed\n")
+ b.WriteString("; by internal/calendar (temporalEF) and is NOT listed here.\n")
+ b.WriteString("; Ranks use the 1960 Code of Rubrics: class-1..class-4.\n")
+ b.WriteString("; Generated by scripts/gen-sanctoral-ef.go from missalemeum (Divinum Officium 1962\n")
+ b.WriteString("; data). Latin names are hand-curated where present. See NOTICE.\n\n")
+ b.WriteString("[layer]\nid = tridentine\nname = General Roman Calendar of 1962\ntype = universal\n")
+ for _, e := range es {
+ fmt.Fprintf(&b, "\n[%s]\ndate = %s\nrank = %s\ncolour = %s\n", e.slug, e.date, e.rank, e.colour)
+ if e.class != "" {
+ fmt.Fprintf(&b, "class = %s\n", e.class)
+ }
+ fmt.Fprintf(&b, "name.en = %s\n", e.en)
+ if e.la != "" {
+ fmt.Fprintf(&b, "name.la = %s\n", e.la)
+ }
+ if e.first != "" {
+ fmt.Fprintf(&b, "reading.first = %s\n", e.first)
+ }
+ if e.gospel != "" {
+ fmt.Fprintf(&b, "reading.gospel = %s\n", e.gospel)
+ }
+ }
+ if err := os.WriteFile("internal/caldata/tridentine-calendar.ini", []byte(b.String()), 0o644); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ nObs := 0
+ for _, e := range es {
+ if e.observed {
+ nObs++
+ }
+ }
+ fmt.Fprintf(os.Stderr, "wrote %d EF sanctoral celebrations (%d observed w/ readings, %d commemoration-only)\n",
+ len(es), nObs, len(es)-nObs)
+}