summaryrefslogtreecommitdiff
path: root/internal/liturgy
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 13:13:27 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-23 13:13:27 +0200
commit8a401a84af52c94f991c74443b7f4397e30cad4a (patch)
tree257728d158c2676fe4643af89ab2bbee9476dd60 /internal/liturgy
parenta8170d4032c6afa175d887b414e6799f088f070c (diff)
downloadlectio-8a401a84af52c94f991c74443b7f4397e30cad4a.tar.gz
lectio-8a401a84af52c94f991c74443b7f4397e30cad4a.zip
liturgy: sigla harvest + offline load
Diffstat (limited to 'internal/liturgy')
-rw-r--r--internal/liturgy/fetch.go20
-rw-r--r--internal/liturgy/store.go175
-rw-r--r--internal/liturgy/store_test.go42
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")
+ }
+}