diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 09:15:40 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 09:15:40 +0200 |
| commit | b93de95dd24ff2a1d3126e6d7d00cd77530a03bf (patch) | |
| tree | 483a0901a20274152ba3f03f272f8098be4f49c0 /internal | |
| parent | c1b954ad517cef00bf480d5229b253a8c6524be7 (diff) | |
| download | lectio-b93de95dd24ff2a1d3126e6d7d00cd77530a03bf.tar.gz lectio-b93de95dd24ff2a1d3126e6d7d00cd77530a03bf.zip | |
tradlit: offline caching + read; update pre-caches traditional; --clean prunes it; v0.2.0
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 51 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/liturgy/clean_test.go | 31 | ||||
| -rw-r--r-- | internal/liturgy/fetch.go | 32 | ||||
| -rw-r--r-- | internal/liturgy/fetch_test.go | 2 | ||||
| -rw-r--r-- | internal/liturgy/store.go | 2 | ||||
| -rw-r--r-- | internal/readings/readings.go | 9 | ||||
| -rw-r--r-- | internal/readings/readings_test.go | 42 | ||||
| -rw-r--r-- | internal/tradlit/tradlit.go | 70 | ||||
| -rw-r--r-- | internal/tradlit/tradlit_test.go | 121 |
10 files changed, 314 insertions, 48 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c514607..130683c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -18,6 +18,7 @@ import ( "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" + "github.com/lukaszkasprzak/lectio/internal/tradlit" ) const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions) @@ -241,18 +242,62 @@ func normalizeLectionary(lectionary string) (string, error) { } // runHarvest handles -u/--update: harvest sigla maximally (to the -// unpublished horizon) from date, printing the outcome or -- on a genuine -// interruption (see liturgy.Harvest) -- the error. +// unpublished horizon) from date for the modern lectionary, printing the +// outcome or -- on a genuine interruption (see liturgy.Harvest) -- the +// error. On a successful harvest it also best-effort pre-caches the +// traditional lectionary's propers (internal/tradlit) for every date in +// that same [date, furthest] window, so 'lectio update' prepares both +// lectionaries for offline use in one run. The 1962 calendar has no +// "unpublished horizon" (every date has propers), so this is a plain +// date-range fetch; a per-date failure is not fatal here -- it never +// prevented the traditional lectionary from working live before, and the +// modern-harvest outcome is still reported either way. func runHarvest(date string, stdout, stderr io.Writer) int { added, furthest, err := liturgy.Harvest(date, 0) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } - fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest) + + lang := config.Default().TraditionalLang + cachedTrad := 0 + if furthest != "" { + if cfg, cfgErr := config.Load(); cfgErr == nil { + lang = cfg.TraditionalLang + cachedTrad = cacheTraditionalRange(date, furthest, lang) + } + } + + fmt.Fprintf(stdout, "harvested %d day(s), furthest %s; cached traditional propers (%s) for %d day(s)\n", + added, furthest, lang, cachedTrad) return 0 } +// cacheTraditionalRange best-effort pre-caches tradlit's traditional propers +// for lang for every date in [from, to] inclusive (walking forward a day at +// a time), returning how many dates succeeded. A per-date failure (e.g. a +// transient network hiccup) is ignored -- the traditional 1962 calendar has +// propers for every date, so there is no "unpublished horizon" to stop at +// the way liturgy.Harvest has for the modern lectionary. +func cacheTraditionalRange(from, to, lang string) int { + start, err := time.Parse("2006-01-02", from) + if err != nil { + return 0 + } + end, err := time.Parse("2006-01-02", to) + if err != nil { + return 0 + } + + cached := 0 + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + if _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil { + cached++ + } + } + return cached +} + // runClean handles -C/--clean: prune cached readings older than one year // (relative to today()) and print a human-readable summary of what was // removed. Like -u/--update, this is a maintenance mode -- it ignores DATE diff --git a/internal/config/config.go b/internal/config/config.go index 7cef184..74588aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). -const Version = "0.1.0" +const Version = "0.2.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ diff --git a/internal/liturgy/clean_test.go b/internal/liturgy/clean_test.go index 4dbe0d9..d9ab4fc 100644 --- a/internal/liturgy/clean_test.go +++ b/internal/liturgy/clean_test.go @@ -7,12 +7,13 @@ import ( "time" ) -// TestCleanCache exercises CleanCache's file-selection rules: only -// "<YYYY-MM-DD>.html"/".json" pairs older than the cutoff are removed; a -// recent pair and a non-matching file are left untouched. The cutoff is a -// fixed literal (not time.Now-derived) so the test is deterministic; only -// the "recent" fixture is anchored to today, and only to make sure it lands -// safely on the "keep" side of that fixed cutoff. +// TestCleanCache exercises CleanCache's file-selection rules: date-prefixed +// cache files (modern ".html"/".json" and traditional ".trad.<lang>.json") +// older than the cutoff are removed; recent files and a non-matching file +// are left untouched. The cutoff is a fixed literal (not time.Now-derived) +// so the test is deterministic; only the "recent" fixtures are anchored to +// today, and only to make sure they land safely on the "keep" side of that +// fixed cutoff. func TestCleanCache(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CACHE_HOME", dir) @@ -23,10 +24,12 @@ func TestCleanCache(t *testing.T) { recent := time.Now().Format("2006-01-02") files := map[string]string{ - "2020-01-01.html": "<html>old</html>", - "2020-01-01.json": `[{"Heading":"old"}]`, - recent + ".html": "<html>recent</html>", - "notes.txt": "not a cache file", + "2020-01-01.html": "<html>old</html>", + "2020-01-01.json": `[{"Heading":"old"}]`, + "2020-01-01.trad.pl.json": `[{"info":{},"sections":[]}]`, + recent + ".html": "<html>recent</html>", + recent + ".trad.pl.json": `[{"info":{},"sections":[]}]`, + "notes.txt": "not a cache file", } for name, content := range files { if err := os.WriteFile(filepath.Join(cacheDir, name), []byte(content), 0o644); err != nil { @@ -39,19 +42,19 @@ func TestCleanCache(t *testing.T) { if err != nil { t.Fatalf("CleanCache: %v", err) } - if removed != 2 { - t.Errorf("removed = %d, want 2", removed) + if removed != 3 { + t.Errorf("removed = %d, want 3", removed) } if freed <= 0 { t.Errorf("freed = %d, want > 0", freed) } - for _, gone := range []string{"2020-01-01.html", "2020-01-01.json"} { + for _, gone := range []string{"2020-01-01.html", "2020-01-01.json", "2020-01-01.trad.pl.json"} { if _, err := os.Stat(filepath.Join(cacheDir, gone)); !os.IsNotExist(err) { t.Errorf("%s still exists after CleanCache, want removed", gone) } } - for _, kept := range []string{recent + ".html", "notes.txt"} { + for _, kept := range []string{recent + ".html", recent + ".trad.pl.json", "notes.txt"} { if _, err := os.Stat(filepath.Join(cacheDir, kept)); err != nil { t.Errorf("%s missing after CleanCache, want kept: %v", kept, err) } diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go index f4f6992..8c887f4 100644 --- a/internal/liturgy/fetch.go +++ b/internal/liturgy/fetch.go @@ -41,11 +41,13 @@ var publishedRe = regexp.MustCompile(`id="\w*0all"`) // validate its own input first. var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) -// cacheFileRe matches the cache file names Load/Harvest write into -// cacheDir(): "<YYYY-MM-DD>.html" or "<YYYY-MM-DD>.json". CleanCache uses it -// to tell cache entries apart from anything else that might be sitting in -// the directory. -var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.(html|json)$`) +// cacheFileRe matches the date-prefixed cache file names Load/Harvest/tradlit +// write into CacheDir(): "<YYYY-MM-DD>.html", "<YYYY-MM-DD>.json", and +// "<YYYY-MM-DD>.trad.<lang>.json" (the traditional-lectionary cache; see +// internal/tradlit). CleanCache uses it to tell cache entries apart from +// anything else that might be sitting in the directory, and to recover the +// date (group 1) for the age check regardless of which cache file it is. +var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.[a-z0-9.]+$`) // Options controls how Load resolves a day's readings. type Options struct { @@ -58,9 +60,11 @@ type Options struct { Offline bool } -// cacheDir is where the HTML/JSON cache layers live: +// CacheDir is where the HTML/JSON cache layers live: // ${XDG_CACHE_HOME:-~/.cache}/lectio/ -func cacheDir() string { +// Exported so internal/tradlit shares the same cache root for the +// traditional lectionary's propers. +func CacheDir() string { base := os.Getenv("XDG_CACHE_HOME") if base == "" { home, err := os.UserHomeDir() @@ -93,7 +97,7 @@ func Load(opts Options) ([]Section, error) { return LoadOffline(opts.Date) } - dir := cacheDir() + dir := CacheDir() jsonPath := filepath.Join(dir, opts.Date+".json") htmlPath := filepath.Join(dir, opts.Date+".html") @@ -142,12 +146,14 @@ func Load(opts Options) ([]Section, error) { } // CleanCache removes cached readings whose date is before `before` from -// cacheDir(). It matches only files named "<YYYY-MM-DD>.html"/".json"; -// anything else in the directory (e.g. a stray notes.txt, or the sigla -// store, which lives elsewhere entirely) is left alone. A missing cache dir -// is not an error -- it just means there is nothing to clean yet. +// CacheDir(). It matches only date-prefixed cache files (see cacheFileRe: +// "<YYYY-MM-DD>.html", ".json", or the traditional lectionary's +// ".trad.<lang>.json"); anything else in the directory (e.g. a stray +// notes.txt, or the sigla store, which lives elsewhere entirely) is left +// alone. A missing cache dir is not an error -- it just means there is +// nothing to clean yet. func CleanCache(before time.Time) (removed int, freed int64, err error) { - dir := cacheDir() + dir := CacheDir() entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go index 1cebb86..738126d 100644 --- a/internal/liturgy/fetch_test.go +++ b/internal/liturgy/fetch_test.go @@ -37,7 +37,7 @@ func TestLoadCaches(t *testing.T) { // before it ever builds a filesystem path from it, so every caller (web, // cli, tui) is protected even if a future caller forgets to validate. // -// The planted "passwd.json" sits one level *above* cacheDir() -- reachable +// The planted "passwd.json" sits one level *above* CacheDir() -- reachable // only via a "../" date -- so if Load ever built jsonPath from the raw date // unchecked, loadJSONCache would read it back and return its section instead // of an error. diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go index cc176e8..69e45f2 100644 --- a/internal/liturgy/store.go +++ b/internal/liturgy/store.go @@ -127,7 +127,7 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro return 0, "", err } - dir := cacheDir() + dir := CacheDir() day := start var harvestErr error for i := 0; maxDays == 0 || i < maxDays; i++ { diff --git a/internal/readings/readings.go b/internal/readings/readings.go index 82d2fb9..ac7209a 100644 --- a/internal/readings/readings.go +++ b/internal/readings/readings.go @@ -5,7 +5,6 @@ package readings import ( - "fmt" "strings" "github.com/lukaszkasprzak/lectio/internal/config" @@ -21,7 +20,8 @@ type Options struct { // (modern lectionary only; see liturgy.Options.Refresh). Refresh bool // Offline restricts Load to previously cached/harvested data, never - // hitting the network. Traditional+Offline is not yet supported. + // 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. @@ -38,10 +38,7 @@ func Load(cfg config.Config, opts Options) ([]liturgy.Section, error) { var err error if cfg.Lectionary == "traditional" { - if offline { - return nil, fmt.Errorf("readings: offline traditional not yet supported; use lectionary=new offline") - } - secs, err = tradlit.Load(opts.Date, cfg.TraditionalLang) + secs, err = tradlit.Load(opts.Date, cfg.TraditionalLang, offline) } else { secs, err = liturgy.Load(liturgy.Options{ Date: opts.Date, diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go index 20df5e0..9a31802 100644 --- a/internal/readings/readings_test.go +++ b/internal/readings/readings_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" @@ -107,14 +108,49 @@ func TestLoadModernRoutes(t *testing.T) { } } -func TestLoadTraditionalOfflineErrors(t *testing.T) { +// 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, "offline") || !strings.Contains(msg, "traditional") { - t.Errorf("error %q should mention offline and traditional", 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"} + secs, 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") } } diff --git a/internal/tradlit/tradlit.go b/internal/tradlit/tradlit.go index edb9ce0..a7b53bc 100644 --- a/internal/tradlit/tradlit.go +++ b/internal/tradlit/tradlit.go @@ -9,6 +9,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "regexp" "strings" @@ -86,11 +88,57 @@ func Parse(jsonBody []byte) ([]liturgy.Section, error) { return out, nil } -// Load fetches a day's traditional propers from the missalemeum API -// (https://www.missalemeum.com/{lang}/api/v5/proper/{date}) and parses -// them. On HTTP 404 (no propers published for that date) it returns a -// clear error rather than attempting to parse. -func Load(date, lang string) ([]liturgy.Section, error) { +// cachePath returns where Load caches a (date, lang) day's raw API response: +// <CacheDir>/<date>.trad.<lang>.json -- date-prefixed (like the modern +// lectionary's own cache files) so liturgy.CleanCache can prune it by date. +func cachePath(date, lang string) string { + return filepath.Join(liturgy.CacheDir(), date+".trad."+lang+".json") +} + +// Load returns a day's traditional propers for lang, either read from the +// on-disk cache (offline) or fetched live from missalemeum and cached for +// next time (online). Both paths share Parse, so cached and live results +// are identical. +// +// - offline: reads the cache file written by a prior online Load (see +// cachePath); if it doesn't exist, returns a clear error telling the +// caller to go online or run 'lectio update' first. +// - online: fetches https://www.missalemeum.com/{lang}/api/v5/proper/{date} +// as before. On a successful 200, the raw response body is written to +// the cache path (best-effort -- a cache-write failure never fails the +// request) before being parsed. On HTTP 404 (no propers published for +// that date) it returns a clear error and writes nothing to the cache. +func Load(date, lang string, offline bool) ([]liturgy.Section, error) { + if offline { + return loadCached(date, lang) + } + return loadLive(date, lang) +} + +// loadCached implements Load's offline path. +func loadCached(date, lang string) ([]liturgy.Section, error) { + body, err := os.ReadFile(cachePath(date, lang)) + if err != nil { + return nil, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang) + } + return Parse(body) +} + +// loadLive implements Load's online path: fetch, best-effort cache the raw +// body, then parse. +func loadLive(date, lang string) ([]liturgy.Section, error) { + body, err := fetch(date, lang) + if err != nil { + return nil, err + } + writeCache(date, lang, body) + return Parse(body) +} + +// fetch GETs the day's proper-of-the-day JSON from missalemeum and returns +// the raw response body. On HTTP 404 (no propers published for that date) +// it returns a clear error rather than the raw 404 body. +func fetch(date, lang string) ([]byte, error) { url := fmt.Sprintf(baseURL, lang, date) req, err := http.NewRequest(http.MethodGet, url, nil) @@ -116,6 +164,16 @@ func Load(date, lang string) ([]liturgy.Section, error) { if err != nil { return nil, fmt.Errorf("tradlit: load %s: %w", date, err) } + return body, nil +} - return Parse(body) +// writeCache best-effort writes a day's raw API response body to its cache +// path; a failure to cache (e.g. an unwritable cache dir) must never fail +// the live request that produced body. +func writeCache(date, lang string, body []byte) { + path := cachePath(date, lang) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + _ = os.WriteFile(path, body, 0o644) } diff --git a/internal/tradlit/tradlit_test.go b/internal/tradlit/tradlit_test.go new file mode 100644 index 0000000..cd2cc83 --- /dev/null +++ b/internal/tradlit/tradlit_test.go @@ -0,0 +1,121 @@ +package tradlit + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lukaszkasprzak/lectio/internal/liturgy" +) + +// TestLoadOnlineCachesRawBody exercises Load's online path: a successful +// fetch is cached verbatim (the raw response body, not the parsed +// sections) at tradlit's cache path, and parses the same way a cached read +// would. +func TestLoadOnlineCachesRawBody(t *testing.T) { + body, err := os.ReadFile("testdata/2026-07-22.json") + if err != nil { + t.Fatal(err) + } + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Write(body) + })) + defer srv.Close() + + orig := baseURL + baseURL = srv.URL + "/%s/api/v5/proper/%s" + defer func() { baseURL = orig }() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + secs, err := Load("2026-07-22", "en", false) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(secs) == 0 { + t.Fatal("Load returned no sections") + } + if hits != 1 { + t.Errorf("server hit %d times, want 1", hits) + } + + cached, err := os.ReadFile(filepath.Join(liturgy.CacheDir(), "2026-07-22.trad.en.json")) + if err != nil { + t.Fatalf("cache file not written: %v", err) + } + if string(cached) != string(body) { + t.Error("cached content does not match the raw response body") + } +} + +// TestLoadOnline404WritesNoCache checks the documented invariant: a 404 (no +// propers published for that date) returns an error and leaves the cache +// directory untouched. +func TestLoadOnline404WritesNoCache(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + orig := baseURL + baseURL = srv.URL + "/%s/api/v5/proper/%s" + defer func() { baseURL = orig }() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + _, err := Load("2026-07-22", "en", false) + if err == nil { + t.Fatal("expected error on 404, got nil") + } + + cachePath := filepath.Join(liturgy.CacheDir(), "2026-07-22.trad.en.json") + if _, statErr := os.Stat(cachePath); !os.IsNotExist(statErr) { + t.Errorf("cache file should not exist after a 404 (stat err = %v)", statErr) + } +} + +// TestLoadOfflineReadsCache exercises Load's offline path against a +// pre-written cache file (as an earlier online Load, or 'lectio update', +// would have left behind) -- no network access at all. +func TestLoadOfflineReadsCache(t *testing.T) { + body, err := os.ReadFile("testdata/2026-07-22.json") + if err != nil { + t.Fatal(err) + } + 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) + } + if err := os.WriteFile(filepath.Join(cacheDir, "2026-07-22.trad.pl.json"), body, 0o644); err != nil { + t.Fatal(err) + } + + secs, err := Load("2026-07-22", "pl", true) + if err != nil { + t.Fatalf("Load offline: %v", err) + } + if len(secs) == 0 { + t.Fatal("Load offline returned no sections") + } +} + +// TestLoadOfflineMissingCacheErrors checks Load's offline path errors +// clearly (mentioning the missing cache) rather than trying the network, +// when nothing has been cached yet for that (date, lang). +func TestLoadOfflineMissingCacheErrors(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + _, err := Load("2026-07-22", "pl", true) + if err == nil { + t.Fatal("expected error for missing cache, got nil") + } + msg := strings.ToLower(err.Error()) + if !strings.Contains(msg, "no cached") { + t.Errorf("error %q should mention no cached propers", err.Error()) + } +} |
