aboutsummaryrefslogtreecommitdiff
path: root/internal/liturgy/store.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/liturgy/store.go')
-rw-r--r--internal/liturgy/store.go175
1 files changed, 175 insertions, 0 deletions
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
+}