From feede51697be870ae183a95b513ef64f031dbd0f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 28 Jul 2026 11:57:45 +0200 Subject: feat(readings): compute the daily view offline (OF + EF) Route readings.Load through the embedded calendar engine and lectionary data instead of the niedziela/missalemeum scrapers. The daily view now computes each day (calendar.Compute + caldata.Readings) and renders text from the public-domain corpora, with citations resolved per corpus. - bible.OFRef: normalise an English-canonical OF citation (drop sub-verse letters, ";" -> ",") and renumber Psalms for the target Psalter (Hebrew->Vulgate chapter for vul/grb/wuj; DRB title-fold verses for drb). - liturgy.Section gains Ref (English-canonical lookup ref) alongside Citation (sigla-dialect display); the loader fills both, resolveRef uses Ref, headings/citation command show Citation. - EffectiveVersions always drops "bt" (no offline corpus); the single daily version defaults to vernacularVersion (reading corpus else Latin). Scraper packages remain in the tree, unreferenced, pending removal. --- internal/bible/ofref.go | 71 +++++++++++++++++++ internal/bible/ofref_test.go | 50 +++++++++++++ internal/cli/cli.go | 28 +++----- internal/cli/cli_test.go | 16 ++--- internal/liturgy/section.go | 18 +++-- internal/readings/offline.go | 140 +++++++++++++++++++++++++++++++++++++ internal/readings/readings.go | 44 ++++-------- internal/readings/readings_test.go | 80 ++++++--------------- internal/render/render.go | 33 ++++----- internal/render/render_test.go | 23 ++---- internal/tui/tui_test.go | 8 +-- internal/web/render_test.go | 4 +- 12 files changed, 347 insertions(+), 168 deletions(-) create mode 100644 internal/bible/ofref.go create mode 100644 internal/bible/ofref_test.go create mode 100644 internal/readings/offline.go (limited to 'internal') diff --git a/internal/bible/ofref.go b/internal/bible/ofref.go new file mode 100644 index 0000000..8a72342 --- /dev/null +++ b/internal/bible/ofref.go @@ -0,0 +1,71 @@ +package bible + +import ( + "regexp" + "strconv" + "strings" + + "github.com/lukaszkasprzak/lectio/internal/psalter" +) + +var ( + // ofCiteRe splits an English-canonical citation into book, chapter and the + // verse tail: "2 Samuel 6:12b-15,17-19" -> ("2 Samuel", "6", "12b-15,17-19"). + // The book is non-greedy so a leading ordinal ("2 Samuel") stays with it. + ofCiteRe = regexp.MustCompile(`^(.*?)\s+(\d+):(.+)$`) + // ofVerseLetterRe drops sub-verse part letters so lookups resolve at whole + // verses: "12b" -> "12", "3ab" -> "3", "3cd" -> "3". + ofVerseLetterRe = regexp.MustCompile(`(\d)[a-z]+`) +) + +// normalizeOFVerses rewrites a citation's verse tail into the shape +// bible.Lookup parses: cross-chapter semicolons become commas, ranges use a +// plain hyphen, sub-verse letters are dropped, and spaces are removed. +func normalizeOFVerses(tail string) string { + tail = strings.ReplaceAll(tail, ";", ",") + tail = dashRe.ReplaceAllString(tail, "-") + tail = ofVerseLetterRe.ReplaceAllString(tail, "$1") + tail = strings.ReplaceAll(tail, " ", "") + return tail +} + +// OFRef turns an English-canonical Ordinary Form citation (lectio's authored +// form) into a bible.Lookup-ready reference for the target corpus's Psalter. +// +// Every citation's verse tail is normalized (see normalizeOFVerses). Only the +// Psalms differ between Psalters, so their chapter/verse is additionally +// renumbered: +// +// - "vulgate" (vul/grb/wuj): shift the Hebrew chapter to its Vulgate +// equivalent (Ps 27 -> 26, Ps 147 -> 146). Verse numbers are kept, so the +// rare intra-psalm verse-boundary shifts are not corrected here. +// - "drb": the Douay-Rheims corpus keeps modern (Hebrew) chapter numbers, so +// the chapter is kept; only the DRB title-fold verse shift applies (see +// psalter.DrbVerse). +// - "hebrew" / anything else: already in the corpus's numbering. +// +// A citation OFRef cannot parse (no "chapter:verse") is returned unchanged for +// bible.Lookup to accept or reject. +func OFRef(citation, system string) string { + m := ofCiteRe.FindStringSubmatch(citation) + if m == nil { + return citation + } + book, chap, verses := m[1], m[2], normalizeOFVerses(m[3]) + + if book == "Psalms" { + heb, err := strconv.Atoi(chap) + if err == nil { + switch system { + case "vulgate": + chap = strconv.Itoa(psalter.HebrewToVulgateChapter(heb)) + case "drb": + verses = digitsRe.ReplaceAllStringFunc(verses, func(s string) string { + n, _ := strconv.Atoi(s) + return strconv.Itoa(psalter.DrbVerse(heb, n)) + }) + } + } + } + return book + " " + chap + ":" + verses +} diff --git a/internal/bible/ofref_test.go b/internal/bible/ofref_test.go new file mode 100644 index 0000000..fae593a --- /dev/null +++ b/internal/bible/ofref_test.go @@ -0,0 +1,50 @@ +package bible + +import "testing" + +func TestOFRef(t *testing.T) { + cases := []struct { + name string + citation string + system string + want string + }{ + // Non-psalms pass through unchanged in every system. + {"gospel-vulgate", "Matthew 4:12-23", "vulgate", "Matthew 4:12-23"}, + {"gospel-drb", "Matthew 4:12-23", "drb", "Matthew 4:12-23"}, + {"epistle-hebrew", "1 Corinthians 1:10-13,17", "hebrew", "1 Corinthians 1:10-13,17"}, + + // Psalm chapter shift for the Vulgate family (Hebrew -> Vulgate). + {"ps27-vulgate", "Psalms 27:1,4,13-14", "vulgate", "Psalms 26:1,4,13-14"}, + {"ps27-drb", "Psalms 27:1,4,13-14", "drb", "Psalms 27:1,4,13-14"}, // Ps 27 has no DRB title fold + {"ps27-hebrew", "Psalms 27:1,4,13-14", "hebrew", "Psalms 27:1,4,13-14"}, + + // Boundary cases of HebrewToVulgateChapter. + {"ps8-vulgate", "Psalms 8:2", "vulgate", "Psalms 8:2"}, // <=8 unchanged + {"ps9-vulgate", "Psalms 9:2", "vulgate", "Psalms 9:2"}, // 9,10 -> 9 + {"ps10-vulgate", "Psalms 10:1", "vulgate", "Psalms 9:1"}, // 10 -> 9 + {"ps11-vulgate", "Psalms 11:1", "vulgate", "Psalms 10:1"}, // 11..113 -> h-1 + {"ps116-vulgate", "Psalms 116:12", "vulgate", "Psalms 114:12"}, + {"ps147-vulgate", "Psalms 147:12", "vulgate", "Psalms 146:12"}, + {"ps148-vulgate", "Psalms 148:1", "vulgate", "Psalms 148:1"}, // >=148 unchanged + + // DRB title-fold verse shift: Ps 51 folds 2 title verses into verse 1. + {"ps51-drb", "Psalms 51:3-4,12-13", "drb", "Psalms 51:1-2,10-11"}, + {"ps51-vulgate", "Psalms 51:3-4", "vulgate", "Psalms 50:3-4"}, + // Ps 63 folds 1 title verse. + {"ps63-drb", "Psalms 63:2,3-4", "drb", "Psalms 63:1,2-3"}, + + // Tail normalization: sub-verse letters dropped, ";" -> ",". + {"verseletter-2sam", "2 Samuel 6:12b-15,17-19", "vulgate", "2 Samuel 6:12-15,17-19"}, + {"semicolon-acts", "The Acts 11:21b-26;13:1-3", "drb", "The Acts 11:21-26,13:1-3"}, + {"multiletter-psalm-drb", "Psalms 98:1,2-3ab,3cd-4,5-6", "drb", "Psalms 98:1,2-3,3-4,5-6"}, + {"multiletter-psalm-vulgate", "Psalms 98:1,2-3ab,3cd-4,5-6", "vulgate", "Psalms 97:1,2-3,3-4,5-6"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := OFRef(c.citation, c.system); got != c.want { + t.Errorf("OFRef(%q, %q) = %q, want %q", c.citation, c.system, got, c.want) + } + }) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f65774f..d671c91 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -396,19 +396,9 @@ func gospelCitation(sec liturgy.Section) string { // is parsed to canonical and re-rendered in the target dialect; a citation // whose book can't be parsed comes back in its source form unchanged. func dialectCitation(cfg config.Config, tbl *bible.BookTable, sec liturgy.Section) string { - raw := gospelCitation(sec) - if raw == "" || tbl == nil { - return raw - } - sourceLang := "pl" - if cfg.Lectionary == "traditional" { - sourceLang = cfg.TraditionalLang - } - canonical, ok := tbl.ParseRef(sourceLang, raw) - if !ok { - return raw - } - return tbl.FormatRef(cfg.SiglaLang(), canonical) + // The offline loader already rendered Citation in the reader's sigla dialect + // (see readings.citationForms), so the gospel reference is shown as-is. + return gospelCitation(sec) } // runExport handles --md/--pdf: load the day's readings and write them as @@ -425,8 +415,8 @@ func runExport(cfg config.Config, date, version string, all, refresh, asPDF bool fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } - if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { - version = vs[0] + if version == "" || version == "bt" { + version = vernacularVersion(cfg) } var data []byte @@ -747,8 +737,12 @@ func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width return 1 } - if vs := render.EffectiveVersions([]string{version}, cfg.Lectionary, cfg.Offline); len(vs) > 0 { - version = vs[0] + // The single-version daily view renders lectio's own reading corpus (an + // explicit reading_version, else the vernacular that matches the UI + // language, else the complete Latin Vulgate -- the same choice -L makes), + // so the former "bt" niedziela default now maps to a real, offline corpus. + if version == "" || version == "bt" { + version = vernacularVersion(cfg) } w := resolveWidth(width, false, stdout) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b08ffba..0e90a04 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -200,23 +200,15 @@ func TestCleanPreferredOverUpdate(t *testing.T) { // banner for the default (non-raw) render, and is entirely absent from // --raw output, which stays text-only for piping. func TestDayInfoHeaderShown(t *testing.T) { - html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") - if err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(html) - })) - defer srv.Close() - liturgy.SetBaseURL(srv.URL + "/liturgia/%s/Ewangelia") - t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + const name = "Saint Mary Magdalene" // universal sanctoral, authored in English + var out, errb bytes.Buffer if code := Run([]string{"2026-07-22"}, nil, &out, &errb); code != 0 { t.Fatalf("Run code=%d stderr=%q", code, errb.String()) } - if !strings.Contains(out.String(), "Święto św. Marii Magdaleny") { + if !strings.Contains(out.String(), name) { t.Errorf("stdout missing day-info header: %q", out.String()) } @@ -225,7 +217,7 @@ func TestDayInfoHeaderShown(t *testing.T) { if code := Run([]string{"2026-07-22", "-r"}, nil, &out, &errb); code != 0 { t.Fatalf("Run --raw code=%d stderr=%q", code, errb.String()) } - if strings.Contains(out.String(), "Święto św. Marii Magdaleny") { + if strings.Contains(out.String(), name) { t.Errorf("--raw stdout should omit the day-info header: %q", out.String()) } } diff --git a/internal/liturgy/section.go b/internal/liturgy/section.go index cacefb0..1e446a1 100644 --- a/internal/liturgy/section.go +++ b/internal/liturgy/section.go @@ -2,13 +2,19 @@ // structured sections (1st reading, psalm, acclamation, gospel). package liturgy -// Section is one reading section of the liturgy page (e.g. "1. czytanie", -// "Psalm", "Aklamacja", "Ewangelia"): its heading, optional subtitle, the -// citation extracted from the heading, a stable identifier for which liturgical -// part it is, and its body split into paragraphs of text lines. +// Section is one reading section of the liturgy (e.g. "1. czytanie", "Psalm", +// "Aklamacja", "Ewangelia"): its heading, optional subtitle, the citation shown +// to the reader, an English-canonical lookup reference, a stable identifier for +// which liturgical part it is, and its body split into paragraphs of text lines. +// +// Citation is the display form (the reader's configured sigla dialect); Ref is +// the English-canonical reference the render resolves against a corpus. Keeping +// them apart lets the header read "Ps 27" in Polish sigla while the lookup uses +// "Psalms 27" (renumbered per Psalter). Ref is empty for sources that only +// carry a display citation; the render then falls back to Citation. type Section struct { - Heading, Subtitle, Citation, PartID string - Paragraphs [][]string + Heading, Subtitle, Citation, Ref, PartID string + Paragraphs [][]string } // DayInfo is the day's liturgical identity, source-language (Polish for the diff --git a/internal/readings/offline.go b/internal/readings/offline.go new file mode 100644 index 0000000..566fc7a --- /dev/null +++ b/internal/readings/offline.go @@ -0,0 +1,140 @@ +package readings + +import ( + "fmt" + "time" + + "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/caldata" + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/liturgy" +) + +// offlineLoad resolves a day's readings entirely from the embedded calendar +// engine and lectionary data -- no network. It returns the same source-agnostic +// liturgy.Section / liturgy.DayInfo the CLI/TUI/web already render, so the daily +// view is unchanged apart from where its data comes from. Citations are +// lectio's English-canonical authored form; the render localises each one to +// the chosen corpus's Psalter and the user's sigla dialect (see +// render.GatherVersion, bible.OFRef). +func offlineLoad(cfg config.Config, date string) ([]liturgy.Section, liturgy.DayInfo, error) { + d, err := time.Parse("2006-01-02", date) + if err != nil { + return nil, liturgy.DayInfo{}, fmt.Errorf("bad date %q (want YYYY-MM-DD)", date) + } + sel := cfg.Selection() + dir, _ := config.CalendarsDir() + layers, _ := caldata.Stack(sel.Form, dir, cfg.Use) // Stack falls back to embedded data on error + day := calendar.Compute(d.UTC(), sel, layers) + rs := caldata.Readings(sel, layers, d.UTC(), day) + tbl, _ := bible.LoadBookTable(config.UserBooksINI()) // nil on error -> citations shown as authored + return sectionsFor(rs, sel.Form, cfg.UILanguage, cfg.SiglaLang(), tbl), dayInfo(cfg, day), nil +} + +// citationForms renders a reading's authored (English) citation into its +// display form (the reader's sigla dialect) and its English-canonical lookup +// reference. When the book table is missing or cannot parse the citation, the +// authored form is used verbatim for both. +func citationForms(raw, siglaLang string, tbl *bible.BookTable) (display, ref string) { + if tbl == nil { + return raw, raw + } + canonical, ok := tbl.ParseRef("en", raw) + if !ok { + return raw, raw + } + return tbl.FormatRef(siglaLang, canonical), canonical +} + +// ofPart maps a computed reading Part to the modern-lectionary section id and +// its Polish heading label. The heading is always the Polish label: the render +// (render.LocalizeHeading) rewrites it to English for an English UI, mirroring +// how the niedziela sections were shaped. +var ofPart = map[string]struct{ id, heading string }{ + "first": {"pierwsze_czytanie", "1. czytanie"}, + "psalm": {"psalm", "Psalm"}, + "second": {"drugie_czytanie", "2. czytanie"}, + "acclamation": {"aklamacja", "Aklamacja"}, + "gospel": {"ewangelia", "Ewangelia"}, +} + +// efPartHeading gives the traditional (1962) section's heading per UI language; +// the EF has only an epistle/lesson and a gospel. Unlike the OF headings these +// are not translated downstream, so they are set in the target language here. +func efPartHeading(part, lang string) (id, heading string) { + pl := lang == "pl" + switch part { + case "first": + if pl { + return "epistola", "Lekcja" + } + return "epistola", "Lesson" + case "gospel": + if pl { + return "evangelium", "Ewangelia" + } + return "evangelium", "Gospel" + } + return "", "" +} + +// sectionsFor turns computed readings into render-ready sections, tagging each +// with the section id and heading its form expects and rendering its citation +// into display (sigla dialect) and lookup (English-canonical) forms. +func sectionsFor(rs []calendar.Reading, form, lang, siglaLang string, tbl *bible.BookTable) []liturgy.Section { + var out []liturgy.Section + for _, r := range rs { + if r.Citation == "" { + continue + } + var id, heading string + if form == "old" { + id, heading = efPartHeading(r.Part, lang) + } else if p, ok := ofPart[r.Part]; ok { + id, heading = p.id, p.heading + } + if id == "" { + continue // an unknown part carries no section + } + display, ref := citationForms(r.Citation, siglaLang, tbl) + out = append(out, liturgy.Section{ + PartID: id, + Heading: heading, + Citation: display, + Ref: ref, + }) + } + return out +} + +// dayInfo builds the header (celebration name, liturgical colour) for the +// computed day. Season is left empty: the celebration name already carries the +// temporal identity for temporal days, and the header is a nice-to-have. +func dayInfo(cfg config.Config, day calendar.LiturgicalDay) liturgy.DayInfo { + return liturgy.DayInfo{ + Name: celebrationName(cfg, day.Observed), + Colour: string(day.Colour), + } +} + +// celebrationName is the observed celebration's name in the UI language, +// falling back to English then a humanized slug for temporal days that carry no +// proper name (mirrors the CLI's cli.celebrationName). An empty result omits +// the header line. +func celebrationName(cfg config.Config, c calendar.Celebration) string { + lang := "en" + if cfg.UILanguage == "pl" { + lang = "pl" + } + if n := c.Name[lang]; n != "" { + return n + } + if n := c.Name["en"]; n != "" { + return n + } + if c.Slug == "" { + return "" + } + return calendar.HumanizeSlug(c.Slug) +} diff --git a/internal/readings/readings.go b/internal/readings/readings.go index 49c9adc..b6fae51 100644 --- a/internal/readings/readings.go +++ b/internal/readings/readings.go @@ -1,7 +1,8 @@ -// Package readings is the single entry point the CLI and TUI use to fetch -// a day's readings. It routes between the modern (liturgy) and traditional -// (tradlit) lectionaries by config, then applies part filtering, returning -// source-agnostic liturgy.Section values either way. +// Package readings is the single entry point the CLI and TUI use to resolve a +// day's readings. It computes them offline from the embedded calendar engine +// and lectionary data (Ordinary Form when cfg.Lectionary is "new", the 1962 +// Extraordinary Form when "traditional"), then applies part filtering, returning +// source-agnostic liturgy.Section values. package readings import ( @@ -9,51 +10,30 @@ import ( "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/liturgy" - "github.com/lukaszkasprzak/lectio/internal/tradlit" ) // Options controls how Load resolves a day's readings. type Options struct { // Date is the day to load, formatted YYYY-MM-DD. Date string - // Refresh bypasses cache layers and re-fetches from the network - // (modern lectionary only; see liturgy.Options.Refresh). + // Refresh and Offline are retained for caller compatibility but no longer + // have any effect: readings are always computed offline from embedded data. Refresh bool - // Offline restricts Load to previously cached/harvested data, never - // hitting the network (both lectionaries; see liturgy.Options.Offline - // and tradlit.Load's offline parameter). Offline bool // All, when true, keeps every part the config doesn't explicitly hide; // when false, only the gospel is kept. All bool } -// Load fetches the day's sections and its DayInfo (celebration name, -// temporal, liturgical colour -- see liturgy.DayInfo) for the configured -// lectionary (cfg.Lectionary: "traditional" or "new") and applies part -// filtering. Returns liturgy.Section values regardless of source. A source -// that yields no DayInfo (see liturgy.Load/tradlit.Load) comes back as a -// zero liturgy.DayInfo, not an error -- callers omit the header for it. +// Load computes the day's sections and its DayInfo (celebration name, +// liturgical colour -- see liturgy.DayInfo) for the configured form +// (cfg.Lectionary: "traditional" or "new") and applies part filtering. Every +// reading is resolved offline from the embedded calendar and lectionary data. func Load(cfg config.Config, opts Options) ([]liturgy.Section, liturgy.DayInfo, error) { - offline := opts.Offline || cfg.Offline - - var secs []liturgy.Section - var info liturgy.DayInfo - var err error - - if cfg.Lectionary == "traditional" { - secs, info, err = tradlit.Load(opts.Date, cfg.TraditionalLang, offline) - } else { - secs, info, err = liturgy.Load(liturgy.Options{ - Date: opts.Date, - Refresh: opts.Refresh, - Offline: offline, - }) - } + secs, info, err := offlineLoad(cfg, opts.Date) if err != nil { return nil, liturgy.DayInfo{}, err } - return filterParts(secs, cfg, opts.All), info, nil } diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go index c9e92cd..f3375f6 100644 --- a/internal/readings/readings_test.go +++ b/internal/readings/readings_test.go @@ -1,10 +1,6 @@ package readings import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" "strings" "testing" @@ -79,18 +75,10 @@ func TestFilterPartsTraditionalAllReadingsOnly(t *testing.T) { } } -func TestLoadModernRoutes(t *testing.T) { - html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") - if err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(html) - })) - defer srv.Close() - liturgy.SetBaseURL(srv.URL + "/liturgia/%s/Ewangelia") - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - +// TestLoadModern computes the Ordinary Form day offline: the gospel section is +// present and the header carries the celebration name (English by default, +// since the universal sanctoral is authored in English). +func TestLoadModern(t *testing.T) { cfg := config.Config{Lectionary: "new"} secs, info, err := Load(cfg, Options{Date: "2026-07-22", All: true}) if err != nil { @@ -106,57 +94,29 @@ func TestLoadModernRoutes(t *testing.T) { if !found { t.Errorf("no gospel section (PartID=ewangelia) found in %+v", secs) } - if !strings.Contains(info.Name, "Marii Magdaleny") { - t.Errorf("DayInfo.Name = %q, want it to contain %q", info.Name, "Marii Magdaleny") + if !strings.Contains(info.Name, "Mary Magdalene") { + t.Errorf("DayInfo.Name = %q, want it to contain %q", info.Name, "Mary Magdalene") } } -// TestLoadTraditionalOfflineErrorsWithoutCache exercises the (formerly -// unsupported) traditional+offline path when nothing has been cached yet -// for that date/lang: it must fail clearly rather than silently falling -// back to the network or to the modern lectionary's sigla store. -func TestLoadTraditionalOfflineErrorsWithoutCache(t *testing.T) { - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"} - _, _, err := Load(cfg, Options{Date: "2026-07-22", Offline: true}) - if err == nil { - t.Fatal("expected error, got nil") - } - msg := strings.ToLower(err.Error()) - if !strings.Contains(msg, "no cached") { - t.Errorf("error %q should mention no cached propers", err.Error()) - } -} - -// TestLoadTraditionalOfflineReadsCache is the positive counterpart: once a -// prior online Load (or 'lectio update') has cached a date's traditional -// propers, Load(offline=true) must serve them from disk, no network -// involved. -func TestLoadTraditionalOfflineReadsCache(t *testing.T) { - dir := t.TempDir() - t.Setenv("XDG_CACHE_HOME", dir) - cacheDir := filepath.Join(dir, "lectio") - if err := os.MkdirAll(cacheDir, 0o755); err != nil { - t.Fatal(err) - } - body, err := os.ReadFile("../tradlit/testdata/2026-07-22.json") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(cacheDir, "2026-07-22.trad.pl.json"), body, 0o644); err != nil { - t.Fatal(err) - } - - cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"} +// TestLoadTraditional computes the Extraordinary Form day offline: it never +// needs the network, and yields the EF epistle+gospel with a header name. +func TestLoadTraditional(t *testing.T) { + cfg := config.Config{Lectionary: "traditional"} secs, info, err := Load(cfg, Options{Date: "2026-07-22", Offline: true, All: true}) if err != nil { t.Fatalf("Load: %v", err) } - if len(secs) == 0 { - t.Error("expected traditional offline sections, got none") + found := false + for _, s := range secs { + if s.PartID == "evangelium" { + found = true + } + } + if !found { + t.Errorf("no EF gospel section (PartID=evangelium) found in %+v", secs) } - if info.Name != "St. Mary Magdalene" { - t.Errorf("DayInfo.Name = %q, want %q", info.Name, "St. Mary Magdalene") + if info.Name == "" { + t.Error("DayInfo.Name empty for a traditional feast day") } } diff --git a/internal/render/render.go b/internal/render/render.go index 6cdb939..3d685d2 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -146,7 +146,12 @@ func GatherVersion(version string, sec liturgy.Section, lectionary, lang string) // it never affects which reference is resolved. func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (string, error) { ui := i18n.Get(lang) - citation := sec.Citation + // Ref is the English-canonical lookup reference; fall back to the display + // citation (or the one embedded in the heading) for sections that carry none. + citation := sec.Ref + if citation == "" { + citation = sec.Citation + } if citation == "" { if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { citation = c @@ -157,13 +162,11 @@ func resolveRef(version string, sec liturgy.Section, lectionary, lang string) (s } if lectionary != "new" { - return citation, nil + return citation, nil // Extraordinary Form: already Vulgate-numbered } - ref, err := bible.ToEnglishRef(citation, system(version)) - if err != nil { - return "", fmt.Errorf(ui.NoReferenceErr, err) - } - return ref, nil + // Ordinary Form: the citation is lectio's English-canonical, modern-numbered + // form; renumber only the Psalms for the target corpus's Psalter. + return bible.OFRef(citation, system(version)), nil } // GatherVerses returns one version's verses for a section as raw bible.Verse @@ -242,16 +245,14 @@ func OfflineVersions(versions []string) []string { } // EffectiveVersions is the version set actually loadable for a request: "bt" -// (the niedziela.pl modern scrape) is dropped -- substituting "wuj" if it was -// the only Polish column, via OfflineVersions -- whenever the scrape is -// unavailable: offline (never fetch) OR the traditional lectionary -// (missalemeum, which has no niedziela.pl scrape). Shared by cli, tui and web -// so all three binaries treat traditional/offline versions identically. +// (the former niedziela.pl modern scrape) has no embedded corpus, so it is +// always dropped -- substituting "wuj" if it was the only Polish column, via +// OfflineVersions. Every reading is now rendered offline from an embedded +// corpus, so the swap is unconditional; the lectionary and offline arguments +// are retained for caller compatibility but no longer change the result. +// Shared by cli, tui and web so all three binaries agree on the version set. func EffectiveVersions(versions []string, lectionary string, offline bool) []string { - if offline || lectionary == "traditional" { - return OfflineVersions(versions) - } - return versions + return OfflineVersions(versions) } // Compare lays the versions of one reading section out as parallel columns, diff --git a/internal/render/render_test.go b/internal/render/render_test.go index e2a26fc..00c8a85 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -25,7 +25,9 @@ func TestGatherBTDedup(t *testing.T) { } func TestGatherBible(t *testing.T) { - sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} + // Offline sections carry the English-canonical lookup reference in Ref; the + // display citation stays in the reader's sigla dialect. + sec := liturgy.Section{Heading: "Ewangelia", Citation: "J 20, 1. 11-18", Ref: "John 20:1,11-18"} label, blocks := GatherVersion("wuj", sec, "new", "pl") if !strings.Contains(label, "Wujek") { t.Errorf("label = %q", label) @@ -109,23 +111,6 @@ func TestGatherVersionNoReferenceLang(t *testing.T) { } } -// TestGatherVersionNoReferenceErrLang checks the "(no reference: ...)" block -// (a citation that bible.ToEnglishRef fails to convert, e.g. an unrecognised -// book) follows lang. -func TestGatherVersionNoReferenceErrLang(t *testing.T) { - sec := liturgy.Section{Citation: "Xyz 1, 1-2"} - - _, blocks := GatherVersion("wuj", sec, "new", "pl") - if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(brak odwołania: ") { - t.Errorf(`GatherVersion(..., "pl") blocks = %v, want prefix "(brak odwołania: "`, blocks) - } - - _, blocks = GatherVersion("wuj", sec, "new", "en") - if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "(no reference: ") { - t.Errorf(`GatherVersion(..., "en") blocks = %v, want prefix "(no reference: "`, blocks) - } -} - func TestOfflineVersions(t *testing.T) { got := OfflineVersions([]string{"bt", "wuj", "vul"}) for _, v := range got { @@ -136,7 +121,7 @@ func TestOfflineVersions(t *testing.T) { } func TestGatherVersesBible(t *testing.T) { - sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} + sec := liturgy.Section{Heading: "Ewangelia", Citation: "J 20, 1. 11-18", Ref: "John 20:1,11-18"} label, verses, versified := GatherVerses("wuj", sec, "new", "pl") if !strings.Contains(label, "Wujek") { t.Errorf("label = %q", label) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index f99c4ac..d4aa855 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -12,16 +12,16 @@ import ( ) func TestVersionCycle(t *testing.T) { - m := New(config.Config{Versions: []string{"bt", "wuj", "vul"}, DefaultVersion: "bt"}, "", "") - if m.version() != "bt" { + m := New(config.Config{Versions: []string{"wuj", "vul", "grb"}, DefaultVersion: "wuj"}, "", "") + if m.version() != "wuj" { t.Fatalf("start = %q", m.version()) } m = m.cycleVersion(+1) - if m.version() != "wuj" { + if m.version() != "vul" { t.Errorf("after tab = %q", m.version()) } m = m.cycleVersion(-1) - if m.version() != "bt" { + if m.version() != "wuj" { t.Errorf("after shift-tab = %q", m.version()) } } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 6002927..c05a83c 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -109,7 +109,7 @@ func TestRenderReadingsVertical(t *testing.T) { } func TestRenderReadingsInterlinear(t *testing.T) { - secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} + secs := []liturgy.Section{{Heading: "Ewangelia", Citation: "J 20, 1. 11-18", Ref: "John 20:1,11-18", PartID: "ewangelia"}} html := string(RenderReadings(secs, []string{"wuj", "vul"}, "new", "interlinear", "pl", liturgy.DayInfo{})) if !strings.Contains(html, "ilverse") { t.Errorf("interlinear output missing ilverse: %q", html[:min(300, len(html))]) @@ -130,7 +130,7 @@ func TestRenderReadingsInterlinear(t *testing.T) { } func TestRenderReadingsInterlinearExcludesBT(t *testing.T) { - secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} + secs := []liturgy.Section{{Heading: "Ewangelia", Citation: "J 20, 1. 11-18", Ref: "John 20:1,11-18", PartID: "ewangelia"}} html := string(RenderReadings(secs, []string{"bt", "vul"}, "new", "interlinear", "pl", liturgy.DayInfo{})) if strings.Contains(html, "Biblia Tysiąclecia (niedziela.pl)") { t.Errorf("interlinear output should substitute wuj for bt, not carry bt's label: %q", html[:min(300, len(html))]) -- cgit v1.3