diff options
Diffstat (limited to 'internal/liturgy/store.go')
| -rw-r--r-- | internal/liturgy/store.go | 175 |
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 +} |
