From 88ade3a7b7e15e6742d367bdc4b95b1293b92429 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 15:22:14 +0200 Subject: feat: EF temporal Sunday lectionary (offline Sunday Mass readings) Unique temporal-day slugs (season-sunday-week); scripts/genlect.go generates tridentine-lectionary.ini from missalemeum keyed by slug (55 entries: the Sundays of the whole EF year). caldata.TemporalReadings(form,slug) lookup; --liturgy resolves proper-of-feast else the temporal cycle and renders the text. bible.ParseRef strips abbreviation dots (1 Cor. -> 1 Cor). 52/53 Sundays fully resolve offline. --- internal/bible/booktable.go | 1 + internal/bible/convert.go | 1 + internal/caldata/caldata.go | 33 +++++ internal/caldata/tridentine-lectionary.ini | 222 +++++++++++++++++++++++++++++ internal/calendar/temporal_ef.go | 12 +- internal/cli/liturgy.go | 31 ++-- scripts/genlect.go | 110 ++++++++++++++ 7 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 internal/caldata/tridentine-lectionary.ini create mode 100644 scripts/genlect.go diff --git a/internal/bible/booktable.go b/internal/bible/booktable.go index de8995c..ffa3907 100644 --- a/internal/bible/booktable.go +++ b/internal/bible/booktable.go @@ -139,6 +139,7 @@ func (t *BookTable) Books(dialect string) []BookInfo { // normalizeSigla. func (t *BookTable) ParseRef(dialect, input string) (string, bool) { low := strings.ToLower(strings.Join(strings.Fields(input), " ")) + low = bookDotRe.ReplaceAllString(low, "$1") // drop abbreviation dots ("1 cor. 9" -> "1 cor 9") if low == "" { return "", false } diff --git a/internal/bible/convert.go b/internal/bible/convert.go index 6181dbf..e7d5944 100644 --- a/internal/bible/convert.go +++ b/internal/bible/convert.go @@ -46,6 +46,7 @@ var ( iRe = regexp.MustCompile(`\s+i\s+`) dashRe = regexp.MustCompile(`\s*[-–—]\s*`) verseLetterRe = regexp.MustCompile(`(\d)[a-c]\b`) + bookDotRe = regexp.MustCompile(`(\p{L})\.`) // abbreviation dot after a letter ("Cor." -> "Cor") psalmRestRe = regexp.MustCompile(`(\d+)(?:\s*\((\d+)\))?(.*)$`) digitsRe = regexp.MustCompile(`\d+`) ) diff --git a/internal/caldata/caldata.go b/internal/caldata/caldata.go index 14b3985..92f9327 100644 --- a/internal/caldata/caldata.go +++ b/internal/caldata/caldata.go @@ -19,6 +19,39 @@ var romanCalendar []byte //go:embed tridentine-calendar.ini var tridentineCalendar []byte +//go:embed tridentine-lectionary.ini +var tridentineLectionary []byte + +// efTempReadings maps an EF temporal-day slug to its readings (parsed once). +var efTempReadings = parseLectionary(tridentineLectionary) + +func parseLectionary(data []byte) map[string][]calendar.Reading { + l, err := ParseLayer(data) + if err != nil { + panic(fmt.Sprintf("caldata: tridentine-lectionary.ini invalid: %v", err)) + } + out := map[string][]calendar.Reading{} + for slug, rc := range l.Cels { + var rs []calendar.Reading + for _, part := range []string{"first", "psalm", "second", "gospel"} { + if c := rc.Fields[part]; c != "" { + rs = append(rs, calendar.Reading{Part: part, Citation: c}) + } + } + out[slug] = rs + } + return out +} + +// TemporalReadings returns the temporal-cycle readings for a computed day slug, +// or nil. Only the EF ("old") temporal lectionary exists so far. +func TemporalReadings(form, slug string) []calendar.Reading { + if form == "old" { + return efTempReadings[slug] + } + return nil +} + // Universal parses the embedded Ordinary Form universal calendar. It panics on a // malformed embedded file (a build-time bug caught by tests). func Universal() calendar.Layer { diff --git a/internal/caldata/tridentine-lectionary.ini b/internal/caldata/tridentine-lectionary.ini new file mode 100644 index 0000000..d0002ad --- /dev/null +++ b/internal/caldata/tridentine-lectionary.ini @@ -0,0 +1,222 @@ +; EF (1962) temporal lectionary, keyed by lectio's computed temporal-day slug. +; Generated from missalemeum (Divinum Officium). Epistle (first) + Gospel citations. + +[ef-advent-sunday-1] +first = Rom 13:11-14 +gospel = Luke 21:25-33 + +[ef-advent-sunday-2] +first = Rom 15:4-13 +gospel = Matt 11:2-10 + +[ef-advent-sunday-3] +first = Phil 4:4-7 +gospel = John 1:19-28 + +[ef-advent-sunday-4] +first = 1 Cor. 4:1-5 +gospel = Luke 3:1-6 + +[ef-christ-the-king] +first = Col 1:12-20. +gospel = John 18:33-37 + +[ef-christmas-sunday-0] +first = Gal 4:1-7 +gospel = Luke 2:33-40 + +[ef-easter-sunday] +first = 1 Cor 5:7-8 +gospel = Mark 16:1-7 + +[ef-easter-sunday-3] +first = 1 Pet 2:21-25 +gospel = John 10:11-16 + +[ef-easter-sunday-4] +first = 1 Pet 2:11-19 +gospel = John 16:16-22 + +[ef-easter-sunday-5] +first = Jas 1:17-21 +gospel = John 16:5-14 + +[ef-easter-sunday-6] +first = Jas 1:22-27 +gospel = John 16:23-30 + +[ef-easter-sunday-7] +first = 1 Pet 4:7-11. +gospel = John 15:26-27; 16:1-4. + +[ef-lent-sunday-1] +first = 2 Cor. 6:1-10 +gospel = Matt 4:1-11 + +[ef-lent-sunday-2] +first = 1 Thess. 4:1-7 +gospel = Matt 17:1-9 + +[ef-lent-sunday-3] +first = Eph 5:1-9 +gospel = Luke 11:14-28 + +[ef-lent-sunday-4] +first = Gal 4:22-31 +gospel = John 6:1-15 + +[ef-low-sunday] +first = 1 John 5:4-10 +gospel = John 20. 19-31 + +[ef-palm-sunday] +first = Phil 2:5-11 +gospel = Matt. 26:36-75; 27:1-60. + +[ef-passion-sunday] +first = Heb 9:11-15. +gospel = John 8:46-59. + +[ef-pentecost] +first = Acts 2:1-11. +gospel = John 14:23-31. + +[ef-septuagesima-sunday-1] +first = 1 Cor. 9:24-27; 10:1-5 +gospel = Matt 20:1-16 + +[ef-septuagesima-sunday-2] +first = 2 Cor. 11:19-33; 12:1-9 +gospel = Luke 8:4-15 + +[ef-septuagesima-sunday-3] +first = 1 Cor. 13:1-13 +gospel = Luke 18:31-43 + +[ef-time-after-epiphany-sunday-1] +first = Col 3:12-17 +gospel = Luke 2:42-52 + +[ef-time-after-epiphany-sunday-2] +first = Rom 12:6-16 +gospel = John 2:1-11 + +[ef-time-after-epiphany-sunday-3] +first = Rom 12:16-21 +gospel = Matt 8:1-13 + +[ef-time-after-epiphany-sunday-4] +first = Mal 3:1-4 +gospel = Luke 2:22-32 + +[ef-time-after-epiphany-sunday-5] +first = Col 3:12-17 +gospel = Matt 13:24-30 + +[ef-time-after-pentecost-sunday-10] +first = 1 Cor. 12:2-11 +gospel = Luke 18:9-14 + +[ef-time-after-pentecost-sunday-11] +first = 1 Cor. 15:1-10 +gospel = Mark 7:31-37 + +[ef-time-after-pentecost-sunday-12] +first = 2 Cor. 3:4-9 +gospel = Luke 10:23-37 + +[ef-time-after-pentecost-sunday-13] +first = Gal 3:16-22 +gospel = Luke 17:11-19 + +[ef-time-after-pentecost-sunday-14] +first = Gal 5:16-24 +gospel = Matt 6:24-33 + +[ef-time-after-pentecost-sunday-15] +first = Gal 5:25-26; 6:1-10 +gospel = Luke 7:11-16 + +[ef-time-after-pentecost-sunday-16] +first = Eph 3:13-21 +gospel = Luke 14:1-11 + +[ef-time-after-pentecost-sunday-17] +first = Eph 4:1-6 +gospel = Matt 22:34-46 + +[ef-time-after-pentecost-sunday-18] +first = 1 Cor. 1:4-8 +gospel = Matt 9:1-8 + +[ef-time-after-pentecost-sunday-19] +first = Eph 4:23-28 +gospel = Matt 22:1-14 + +[ef-time-after-pentecost-sunday-2] +first = 1 John 3:13-18. +gospel = Luke 14:16-24. + +[ef-time-after-pentecost-sunday-20] +first = Eph 5:15-21 +gospel = John 4:46-53 + +[ef-time-after-pentecost-sunday-21] +first = Eph 6:10-17 +gospel = Matt 18:23-35 + +[ef-time-after-pentecost-sunday-22] +first = Phil 1:6-11 +gospel = Matt 22:15-21 + +[ef-time-after-pentecost-sunday-23] +first = Phil 3:17-21; 4:1-3 +gospel = Matt 9:18-26 + +[ef-time-after-pentecost-sunday-24] +first = Rom 13:8-10 +gospel = Matt 8:23-27 + +[ef-time-after-pentecost-sunday-25] +first = Col 3:12-17 +gospel = Matt 13:24-30 + +[ef-time-after-pentecost-sunday-26] +first = 1 Thess. 1:2-10 +gospel = Matt 13:31-35 + +[ef-time-after-pentecost-sunday-27] +first = Col 1:9-14 +gospel = Matt 24:15-35 + +[ef-time-after-pentecost-sunday-3] +first = 1 Pet. 5:6-11 +gospel = Luke 15:1-10 + +[ef-time-after-pentecost-sunday-4] +first = Rom 8:18-23 +gospel = Luke 5:1-11 + +[ef-time-after-pentecost-sunday-5] +first = 1 Pet 3:8-15. +gospel = Matt 5:20-24. + +[ef-time-after-pentecost-sunday-6] +first = Rom 6:3-11. +gospel = Mark 8:1-9 + +[ef-time-after-pentecost-sunday-7] +first = Rom 6:19-23 +gospel = Matt 7:15-21 + +[ef-time-after-pentecost-sunday-8] +first = Rom 8:12-17 +gospel = Luke 16:1-9 + +[ef-time-after-pentecost-sunday-9] +first = 1 Cor. 10:6-13 +gospel = Luke 19:41-47 + +[ef-trinity] +first = Rom 11:33-36. +gospel = Matt 28:18-20 diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go index 725aac2..151bd1b 100644 --- a/internal/calendar/temporal_ef.go +++ b/internal/calendar/temporal_ef.go @@ -1,6 +1,9 @@ package calendar -import "time" +import ( + "strconv" + "time" +) // Extraordinary Form (1962) season constants. Advent/Christmas/Lent/Easter are // shared with the OF; these are EF-specific. @@ -114,11 +117,12 @@ func temporalEF(date time.Time) temporalDay { // Sundays vs ferias of the current season. if sun { + week := efWeek(date, season, y, easter) rank := RankClass2 if season == Advent && sameDay(date, adventStart(y)) { rank = RankClass1 // 1st Sunday of Advent } - return efCel(season, "ef-"+string(season)+"-sunday", col, rank, efWeek(date, season, y, easter)) + return efCel(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), col, rank, week) } rank := RankClass4 // per-annum feria if season == Advent || season == Lent || season == Passiontide || season == Septuagesima { @@ -140,6 +144,10 @@ func efWeek(date time.Time, season Season, y int, easter time.Time) int { case TimeAfterEpiphany: jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC) return daysBetween(jan6, date)/7 + 1 + case Septuagesima: + return daysBetween(easter.AddDate(0, 0, -63), date)/7 + 1 + case Easter_: + return daysBetween(easter, date)/7 + 1 default: return 0 } diff --git a/internal/cli/liturgy.go b/internal/cli/liturgy.go index 840e9a6..975917a 100644 --- a/internal/cli/liturgy.go +++ b/internal/cli/liturgy.go @@ -78,22 +78,25 @@ func printLiturgicalDay(out io.Writer, cfg config.Config, tbl *bible.BookTable, fmt.Fprintf(out, " also: %s [%s]\n", n, rankLabel(o.Rank)) } } - vern := vernacularVersion(cfg) + // Readings: the observed celebration's inline propers, else the temporal + // lectionary for the day (proper-of-feast overrides the temporal cycle). + var readings []calendar.Reading for _, m := range day.Observed.Masses { - for _, r := range m.Readings { - label := r.Part - if m.Variant != "" { - label = m.Variant + " " + label - } - disp, txt := readingLine(tbl, cfg, vern, r.Citation) - fmt.Fprintf(out, "\n %s (%s):\n", label, disp) - if txt != "" { - for _, line := range strings.Split(render.Wrap(txt, 72), "\n") { - fmt.Fprintf(out, " %s\n", line) - } - } else { - fmt.Fprintf(out, " (text not in %s)\n", vern) + readings = append(readings, m.Readings...) + } + if len(readings) == 0 { + readings = caldata.TemporalReadings(cfg.Selection().Form, day.Observed.Slug) + } + vern := vernacularVersion(cfg) + for _, r := range readings { + disp, txt := readingLine(tbl, cfg, vern, r.Citation) + fmt.Fprintf(out, "\n %s (%s):\n", r.Part, disp) + if txt != "" { + for _, line := range strings.Split(render.Wrap(txt, 72), "\n") { + fmt.Fprintf(out, " %s\n", line) } + } else { + fmt.Fprintf(out, " (text not in %s)\n", vern) } } } diff --git a/scripts/genlect.go b/scripts/genlect.go new file mode 100644 index 0000000..eec21b6 --- /dev/null +++ b/scripts/genlect.go @@ -0,0 +1,110 @@ +//go:build ignore + +// genlect generates the EF temporal Sunday lectionary from missalemeum +// (Divinum Officium data), keyed by lectio's computed temporal-day slug. +// One-time; requires network. Run from the repo root: +// go run scripts/genlect.go +// Writes internal/caldata/tridentine-lectionary.ini. +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "sort" + "strings" + "time" + + "github.com/lukaszkasprzak/lectio/internal/caldata" + "github.com/lukaszkasprzak/lectio/internal/calendar" +) + +var citeRe = regexp.MustCompile(`\*([^*]+)\*`) + +func fetchCitations(date string) (epistle, gospel string) { + resp, err := http.Get("https://www.missalemeum.com/en/api/v5/proper/" + date) + if err != nil { + return + } + defer resp.Body.Close() + var data []struct { + Sections []struct { + ID string `json:"id"` + Body [][]string `json:"body"` // [[english, latin]] + } `json:"sections"` + } + if json.NewDecoder(resp.Body).Decode(&data) != nil || len(data) == 0 { + return + } + for _, s := range data[0].Sections { + if len(s.Body) == 0 || len(s.Body[0]) == 0 { + continue + } + m := citeRe.FindStringSubmatch(s.Body[0][0]) // english text carries *citation* + if m == nil { + continue + } + switch s.ID { + case "Lectio": + epistle = strings.TrimSpace(m[1]) + case "Evangelium": + gospel = strings.TrimSpace(m[1]) + } + } + return +} + +func main() { + sel := calendar.DefaultSelection() + sel.Form = "old" + layers := []calendar.Layer{caldata.Tridentine()} + lect := map[string][2]string{} // slug -> {epistle, gospel} + + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2028, 12, 31, 0, 0, 0, 0, time.UTC) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + if d.Weekday() != time.Sunday { + continue + } + day := calendar.Compute(d, sel, layers) + if day.Observed.Layer != "temporal" { // a feast won that Sunday -> its own propers + continue + } + slug := day.Observed.Slug + if _, ok := lect[slug]; ok { + continue // one instance per slug + } + ep, gos := fetchCitations(d.Format("2006-01-02")) + if ep == "" && gos == "" { + continue + } + lect[slug] = [2]string{ep, gos} + fmt.Fprintf(os.Stderr, "%s %-34s ep=%-22s go=%s\n", d.Format("2006-01-02"), slug, ep, gos) + } + + slugs := make([]string, 0, len(lect)) + for s := range lect { + slugs = append(slugs, s) + } + sort.Strings(slugs) + var b strings.Builder + b.WriteString("; EF (1962) temporal lectionary, keyed by lectio's computed temporal-day slug.\n") + b.WriteString("; Generated from missalemeum (Divinum Officium). Epistle (first) + Gospel citations.\n") + for _, s := range slugs { + v := lect[s] + fmt.Fprintf(&b, "\n[%s]\n", s) + if v[0] != "" { + fmt.Fprintf(&b, "first = %s\n", v[0]) + } + if v[1] != "" { + fmt.Fprintf(&b, "gospel = %s\n", v[1]) + } + } + if err := os.WriteFile("internal/caldata/tridentine-lectionary.ini", []byte(b.String()), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "wrote %d temporal entries\n", len(lect)) +} -- cgit v1.3