diff options
| -rw-r--r-- | internal/liturgy/fetch.go | 20 | ||||
| -rw-r--r-- | internal/liturgy/store.go | 175 | ||||
| -rw-r--r-- | internal/liturgy/store_test.go | 42 |
3 files changed, 233 insertions, 4 deletions
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go index 1b1458f..d6cc4b6 100644 --- a/internal/liturgy/fetch.go +++ b/internal/liturgy/fetch.go @@ -32,9 +32,8 @@ type Options struct { Date string // Refresh bypasses both cache layers and re-fetches from the network. Refresh bool - // Offline restricts Load to previously cached/harvested data, never - // hitting the network. Its behavior is added in a later task; for now - // it is a no-op on the modern-source path. + // Offline restricts Load to previously cached/harvested data (see + // LoadOffline), never hitting the network. Offline bool } @@ -55,12 +54,20 @@ func cacheDir() string { // Load returns the day's reading sections, from the modern (niedziela.pl) // source, preferring cache over network: // +// 0. If Offline, skip straight to LoadOffline (the harvested sigla TSV). // 1. Unless Refresh, the parsed JSON cache ({date}.json). // 2. Unless Refresh, the raw HTML cache ({date}.html) -- parsed, and the // result written to the JSON cache for next time. // 3. Otherwise, fetch the page over the network, parse it, and -- only if -// the page is fully published -- write both cache layers. +// the page is fully published -- write both cache layers. A fetch error +// here (e.g. no network) falls back to LoadOffline for this date if it +// has been harvested, and only surfaces the original fetch error if +// that fallback also fails. func Load(opts Options) ([]Section, error) { + if opts.Offline { + return LoadOffline(opts.Date) + } + dir := cacheDir() jsonPath := filepath.Join(dir, opts.Date+".json") htmlPath := filepath.Join(dir, opts.Date+".html") @@ -81,6 +88,11 @@ func Load(opts Options) ([]Section, error) { page, err := fetch(opts.Date) if err != nil { + // Network is unreachable: fall back to a prior harvest of this date + // if there is one, rather than failing outright. + if secs, offErr := LoadOffline(opts.Date); offErr == nil { + return secs, nil + } return nil, err } diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go new file mode 100644 index 0000000..0dc8016 --- /dev/null +++ b/internal/liturgy/store.go @@ -0,0 +1,175 @@ +package liturgy + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// siglaRow is one line of the sigla TSV: a date's section label and the +// scripture citation extracted from its heading. +type siglaRow struct { + label, citation string +} + +// siglaPath is the persistent sigla store written by Harvest and read by +// LoadOffline: ${XDG_DATA_HOME:-~/.local/share}/lectio/sigla.tsv +func siglaPath() string { + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "lectio", "sigla.tsv") +} + +// sectionLabel derives the short label Harvest stores alongside a citation +// from a section's full heading, e.g. "Ewangelia (J 20, 1. 11-18)" -> +// "Ewangelia". It strips the same trailing "(...)" citation ExtractCitation +// reads, so the two stay in sync. +func sectionLabel(heading string) string { + loc := citationRe.FindStringIndex(heading) + if loc == nil { + return strings.TrimSpace(heading) + } + return strings.TrimSpace(heading[:loc[0]]) +} + +// readSigla loads the sigla TSV into date -> rows. A missing file is not an +// error -- it just means nothing has been harvested yet. +func readSigla(path string) (map[string][]siglaRow, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string][]siglaRow{}, nil + } + return nil, err + } + rows := map[string][]siglaRow{} + for _, line := range strings.Split(string(data), "\n") { + if line == "" { + continue + } + fields := strings.SplitN(line, "\t", 3) + if len(fields) != 3 { + continue + } + date := fields[0] + rows[date] = append(rows[date], siglaRow{label: fields[1], citation: fields[2]}) + } + return rows, nil +} + +// writeSigla writes date -> rows back out as the sigla TSV, sorted by date +// for a deterministic, diffable file. +func writeSigla(path string, byDate map[string][]siglaRow) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + dates := make([]string, 0, len(byDate)) + for d := range byDate { + dates = append(dates, d) + } + sort.Strings(dates) + + var b strings.Builder + for _, date := range dates { + for _, r := range byDate[date] { + fmt.Fprintf(&b, "%s\t%s\t%s\n", date, r.label, r.citation) + } + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// Harvest walks dates forward from fromDate, fetching and parsing each day's +// page and recording every section's citation to the sigla TSV, for up to +// maxDays days (0 = walk until the unpublished horizon). It stops the first +// time a date fails to fetch or parse -- niedziela.pl's "Przykro nam" +// placeholder (or any other parse failure) marks the horizon the site +// hasn't published past yet, not an error to report. +// +// Re-harvesting a date replaces its rows in the TSV rather than duplicating +// them, so running Harvest again over an already-harvested range is safe. +// It also warms the HTML/JSON cache for every date it successfully harvests. +// +// It returns how many days were harvested and the furthest (most recent) +// date reached. +func Harvest(fromDate string, maxDays int) (added int, furthest string, err error) { + start, err := time.Parse("2006-01-02", fromDate) + if err != nil { + return 0, "", fmt.Errorf("invalid date %q: %w", fromDate, err) + } + + path := siglaPath() + byDate, err := readSigla(path) + if err != nil { + return 0, "", err + } + + dir := cacheDir() + day := start + for i := 0; maxDays == 0 || i < maxDays; i++ { + dateStr := day.Format("2006-01-02") + + page, ferr := fetch(dateStr) + if ferr != nil { + break // unreachable site or network error: stop, not a hard failure + } + secs, perr := Parse(page) + if perr != nil { + break // unpublished horizon (or unparsable page): stop walking + } + + var rows []siglaRow + for _, s := range secs { + citation, cerr := ExtractCitation(s.Heading) + if cerr != nil { + continue + } + rows = append(rows, siglaRow{label: sectionLabel(s.Heading), citation: citation}) + } + byDate[dateStr] = rows + + if publishedRe.MatchString(page) { + if mkErr := os.MkdirAll(dir, 0o755); mkErr == nil { + _ = os.WriteFile(filepath.Join(dir, dateStr+".html"), []byte(page), 0o644) + writeJSONCache(filepath.Join(dir, dateStr+".json"), secs) + } + } + + added++ + furthest = dateStr + day = day.AddDate(0, 0, 1) + } + + if err := writeSigla(path, byDate); err != nil { + return added, furthest, err + } + return added, furthest, nil +} + +// LoadOffline builds a day's sections purely from the harvested sigla TSV: +// Heading is the stored section label, Citation the stored citation, and +// Paragraphs empty (no reading text is harvested, only the scripture +// reference). It errors clearly if the date has not been harvested. +func LoadOffline(date string) ([]Section, error) { + byDate, err := readSigla(siglaPath()) + if err != nil { + return nil, err + } + rows, ok := byDate[date] + if !ok || len(rows) == 0 { + return nil, fmt.Errorf("%s not harvested; run 'lectio update' while online first", date) + } + secs := make([]Section, 0, len(rows)) + for _, r := range rows { + secs = append(secs, Section{Heading: r.label, Citation: r.citation}) + } + return secs, nil +} diff --git a/internal/liturgy/store_test.go b/internal/liturgy/store_test.go new file mode 100644 index 0000000..979b091 --- /dev/null +++ b/internal/liturgy/store_test.go @@ -0,0 +1,42 @@ +package liturgy + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestHarvestAndOffline(t *testing.T) { + html, _ := os.ReadFile("testdata/2026-07-22.html") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "2026-07-22") { + w.Write(html) + } else { + w.Write([]byte("<html>Przykro nam</html>")) // horizon + } + })) + defer srv.Close() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + baseURL = srv.URL + "/liturgia/%s/Ewangelia" + + added, _, err := Harvest("2026-07-22", 3) + if err != nil || added < 1 { + t.Fatalf("harvest: added=%d err=%v", added, err) + } + secs, err := LoadOffline("2026-07-22") + if err != nil { + t.Fatal(err) + } + var haveGospel bool + for _, s := range secs { + if s.Citation == "J 20, 1. 11-18" { + haveGospel = true + } + } + if !haveGospel { + t.Error("offline gospel citation missing") + } +} |
