summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/bible/ofref.go71
-rw-r--r--internal/bible/ofref_test.go50
-rw-r--r--internal/cli/cli.go28
-rw-r--r--internal/cli/cli_test.go16
-rw-r--r--internal/liturgy/section.go18
-rw-r--r--internal/readings/offline.go140
-rw-r--r--internal/readings/readings.go44
-rw-r--r--internal/readings/readings_test.go80
-rw-r--r--internal/render/render.go33
-rw-r--r--internal/render/render_test.go23
-rw-r--r--internal/tui/tui_test.go8
-rw-r--r--internal/web/render_test.go4
12 files changed, 347 insertions, 168 deletions
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")
- }
-}
-
-// 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())
+ if !strings.Contains(info.Name, "Mary Magdalene") {
+ t.Errorf("DayInfo.Name = %q, want it to contain %q", info.Name, "Mary Magdalene")
}
}
-// 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))])