diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 11:57:45 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-28 11:57:45 +0200 |
| commit | feede51697be870ae183a95b513ef64f031dbd0f (patch) | |
| tree | 6700a80f996d2bba204c0452db2d2bbc16bff619 /internal/readings | |
| parent | 626f2275438a37e5cd2efb5964281b9bef5ce8cc (diff) | |
| download | lectio-feede51697be870ae183a95b513ef64f031dbd0f.tar.gz lectio-feede51697be870ae183a95b513ef64f031dbd0f.zip | |
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.
Diffstat (limited to 'internal/readings')
| -rw-r--r-- | internal/readings/offline.go | 140 | ||||
| -rw-r--r-- | internal/readings/readings.go | 44 | ||||
| -rw-r--r-- | internal/readings/readings_test.go | 80 |
3 files changed, 172 insertions, 92 deletions
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") } } |
