package liturgy import ( "fmt" "os" "path/filepath" "sort" "strings" "time" ) // harvestRetries is how many times Harvest attempts a single date's fetch // before treating it as a genuine network failure rather than transient // hiccup; harvestRetryDelay is the backoff slept between attempts. Both are // package vars so tests can shrink the delay instead of waiting on it. var ( harvestRetries = 3 harvestRetryDelay = 2 * time.Second ) // 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 cleanly // (nil error) the first time a date fails to 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. A date that // fails to FETCH, by contrast, is a transient network problem, not the // horizon: Harvest retries it a few times (harvestRetries, backing off // harvestRetryDelay between attempts) and, if it still fails, stops and // returns an error -- but only after saving whatever was harvested up to // that point, so the caller never loses progress to a blip. // // 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, alongside any fetch error (nil on a clean parse-horizon // stop or on reaching maxDays). 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 var harvestErr error for i := 0; maxDays == 0 || i < maxDays; i++ { dateStr := day.Format("2006-01-02") var page string var ferr error for attempt := 1; attempt <= harvestRetries; attempt++ { page, ferr = fetch(dateStr) if ferr == nil { break } if attempt < harvestRetries { time.Sleep(harvestRetryDelay) } } if ferr != nil { // A genuine network/transport error, not the horizon: don't // silently stop as if the site simply hadn't published this // date yet. Record it and stop walking, but writeSigla below // still runs so progress made so far isn't lost. harvestErr = fmt.Errorf("harvest interrupted at %s: %w", dateStr, ferr) break } secs, perr := Parse(page) if perr != nil { break // unpublished horizon (or unparsable page): stop walking, cleanly } 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) } werr := writeSigla(path, byDate) if harvestErr != nil { return added, furthest, harvestErr } if werr != nil { return added, furthest, werr } 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)) czytanieCount := 0 for _, r := range rows { secs = append(secs, Section{ Heading: r.label, Citation: r.citation, PartID: partID(r.label, &czytanieCount), }) } return secs, nil }