diff options
| -rw-r--r-- | internal/cli/cli.go | 374 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 114 | ||||
| -rw-r--r-- | internal/liturgy/store.go | 55 | ||||
| -rw-r--r-- | internal/liturgy/store_test.go | 51 |
4 files changed, 362 insertions, 232 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 66f45a7..e7a2439 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,6 +1,6 @@ -// Package cli is lectio's subcommand dispatcher: it wires config, the -// readings router, liturgy.Harvest and render together into the `lectio` -// binary's Run entry point. +// Package cli is lectio's flag-driven command dispatcher: it wires config, +// the readings router, liturgy.Harvest and render together into the +// `lectio` binary's Run entry point. package cli import ( @@ -25,36 +25,35 @@ const versionString = "0.1.0" const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions) Usage: - lectio [today] [--all] [--raw] [--width N] [--refresh] - lectio date D [--all] [--raw] [--width N] [--refresh] D = YYYY-MM-DD - lectio show VERSION [--date D] [--all] [--raw] [--width N] [--refresh] - one version's text - lectio compare LIST [--date D] [--all] [--width N] [--refresh] [--raw] - LIST = comma-separated versions (default: config "versions") - lectio update [--days N] [--from D] harvest future sigla to the TSV - lectio --version | lectio -v - lectio help | lectio -h | lectio <cmd> -h + lectio [DATE] [flags] DATE = YYYY-MM-DD (default: today), any position -Global flags (any subcommand, any position): - --offline skip the network, use cached/harvested data only - --lectionary new|traditional override config "lectionary" for this run - --lang pl|en override config "traditional_lang" for this run - -Per-command flags: - --all show every reading part (1st/2nd, psalm, acclamation, - gospel); default is the gospel only, unless config - all = true - --raw omit the banner/headings (for piping) - --width N wrap width; 0 = detect terminal, else a sane default - (80 normally, 120 for compare) - --refresh bypass the cache and re-fetch - --date D date as YYYY-MM-DD (show/compare; default: today) +Flags: + -a, --all all readings, not just the gospel + -b, --bible VER one version's text: pl,wuj,vul,grb,drb + -c, --compare LIST versions side by side (comma-separated) + -r, --raw text only, no banner/headings (for piping) + -w, --width N wrap width; 0 = detect terminal + -R, --refresh ignore cache, re-download + -o, --offline cache/sigla only, no network + -l, --lectionary WHICH new|trad (trad -> traditional) + -g, --lang LANG traditional lectionary language: pl|en + -u, --update harvest sigla maximally to the horizon (idempotent) + -v, --version print the version and exit + -h, --help this help Versions: pl (Polski/niedziela.pl) wuj (Wujek) vul (Wulgata) grb (Grecki) drb (Douay-Rheims) +Examples: + lectio today's gospel + lectio 2026-07-22 -a all readings for a date + lectio -c pl,wuj 2026-07-22 compare two versions for a date + lectio -b vul -a Vulgate text, all readings + lectio -l trad -a traditional lectionary, all readings + lectio -u harvest sigla to the horizon + Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), -2 usage error (unknown command/version, bad date, missing argument). +2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang). ` const ( @@ -91,9 +90,52 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } - offline, lectionary, lang, rest := extractGlobal(args) - if lectionary != "" && lectionary != "new" && lectionary != "traditional" { - fmt.Fprintf(stderr, "lectio: invalid --lectionary %q (want new|traditional)\n", lectionary) + date, rest, err := extractDate(args) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 2 + } + + var all, raw, refresh, offline, update bool + var bibleVer, compareList, lectionary, lang string + var width int + + fs := flag.NewFlagSet("lectio", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, helpText) } + + fs.BoolVar(&all, "a", false, "all readings, not just the gospel") + fs.BoolVar(&all, "all", false, "all readings, not just the gospel") + fs.StringVar(&bibleVer, "b", "", "one version's text: pl,wuj,vul,grb,drb") + fs.StringVar(&bibleVer, "bible", "", "one version's text: pl,wuj,vul,grb,drb") + fs.StringVar(&compareList, "c", "", "versions side by side (comma list)") + fs.StringVar(&compareList, "compare", "", "versions side by side (comma list)") + fs.BoolVar(&raw, "r", false, "text only, no banner/headings") + fs.BoolVar(&raw, "raw", false, "text only, no banner/headings") + fs.IntVar(&width, "w", 0, "wrap width (0 = detect terminal)") + fs.IntVar(&width, "width", 0, "wrap width (0 = detect terminal)") + fs.BoolVar(&refresh, "R", false, "ignore cache, re-download") + fs.BoolVar(&refresh, "refresh", false, "ignore cache, re-download") + fs.BoolVar(&offline, "o", false, "cache/sigla only, no network") + fs.BoolVar(&offline, "offline", false, "cache/sigla only, no network") + fs.StringVar(&lectionary, "l", "", "new|trad") + fs.StringVar(&lectionary, "lectionary", "", "new|trad") + fs.StringVar(&lang, "g", "", "pl|en") + fs.StringVar(&lang, "lang", "", "pl|en") + fs.BoolVar(&update, "u", false, "harvest sigla maximally to the horizon") + fs.BoolVar(&update, "update", false, "harvest sigla maximally to the horizon") + + if err := fs.Parse(rest); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "lectio: unexpected argument %q; see 'lectio -h'\n", fs.Arg(0)) + return 2 + } + + lectionary, err = normalizeLectionary(lectionary) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) return 2 } if lang != "" && lang != "pl" && lang != "en" { @@ -101,46 +143,42 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } - cmd := "today" - cmdArgs := rest - if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") { - cmd = rest[0] - cmdArgs = rest[1:] + if update { + return runHarvest(date, stdout, stderr) } - switch cmd { - case "today", "date", "show", "compare": - cfg, err := config.Load() - if err != nil { - fmt.Fprintln(stderr, "lectio:", err) - return 1 - } - if offline { - cfg.Offline = true - } - if lectionary != "" { - cfg.Lectionary = lectionary - } - if lang != "" { - cfg.TraditionalLang = lang - } - switch cmd { - case "today": - return runToday(cmdArgs, cfg, stdout, stderr) - case "date": - return runDate(cmdArgs, cfg, stdout, stderr) - case "show": - return runShow(cmdArgs, cfg, stdout, stderr) - case "compare": - return runCompare(cmdArgs, cfg, stdout, stderr) + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + if offline { + cfg.Offline = true + } + if lectionary != "" { + cfg.Lectionary = lectionary + } + if lang != "" { + cfg.TraditionalLang = lang + } + + effAll := all || cfg.All + effWidth := width + if effWidth == 0 { + effWidth = cfg.Width + } + + if compareList != "" { + return renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, stdout, stderr) + } + if bibleVer != "" { + if !validVersions[bibleVer] { + fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", bibleVer) + return 2 } - return 1 // unreachable - case "update": - return runUpdate(cmdArgs, stdout, stderr) - default: - fmt.Fprintf(stderr, "lectio: unknown command %q; see 'lectio help'\n", cmd) - return 2 + return fetchAndPrint(cfg, bibleVer, date, effAll, raw, effWidth, refresh, stdout, stderr) } + return fetchAndPrint(cfg, cfg.DefaultVersion, date, effAll, raw, effWidth, refresh, stdout, stderr) } // wantsHelp reports whether -h/--help appears anywhere in args. @@ -153,107 +191,64 @@ func wantsHelp(args []string) bool { return false } -// extractGlobal pulls the global --offline/--lectionary/--lang flags out of -// args (they may appear anywhere, before or after the subcommand), leaving -// rest for subcommand dispatch and per-command flag parsing. -func extractGlobal(args []string) (offline bool, lectionary, lang string, rest []string) { - for i := 0; i < len(args); i++ { - a := args[i] - switch { - case a == "--offline": - offline = true - case a == "--lectionary" && i+1 < len(args): - lectionary = args[i+1] - i++ - case strings.HasPrefix(a, "--lectionary="): - lectionary = strings.TrimPrefix(a, "--lectionary=") - case a == "--lang" && i+1 < len(args): - lang = args[i+1] - i++ - case strings.HasPrefix(a, "--lang="): - lang = strings.TrimPrefix(a, "--lang=") - default: - rest = append(rest, a) +// extractDate pulls the single positional DATE token (YYYY-MM-DD, matching +// dateRe) out of args, wherever it appears, and returns it along with the +// remaining tokens for flag.FlagSet to parse. No flag's own value can match +// dateRe's shape (width is an int, versions/lists/lang/lectionary are short +// codes), so scanning raw tokens this way is unambiguous. Defaults to +// today() when no date token is present; errors if more than one is found. +func extractDate(args []string) (date string, rest []string, err error) { + found := false + for _, a := range args { + if dateRe.MatchString(a) { + if found { + return "", nil, fmt.Errorf("multiple dates given (%q and %q)", date, a) + } + date = a + found = true + continue } + rest = append(rest, a) } - return offline, lectionary, lang, rest -} - -// runToday handles the (default) "today" subcommand: --all --raw --width -// --refresh, date = today. -func runToday(args []string, cfg config.Config, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("today", flag.ContinueOnError) - fs.SetOutput(stderr) - all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") - raw := fs.Bool("raw", false, "omit banner/headings (for piping)") - width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") - refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() > 0 { - fmt.Fprintf(stderr, "lectio: today takes no date argument (use: lectio date %s)\n", fs.Arg(0)) - return 2 + if !found { + date = today() } - return fetchAndPrint(cfg, cfg.DefaultVersion, today(), *all, *raw, *width, *refresh, stdout, stderr) + return date, rest, nil } -// runDate handles "date D": D is the required first positional argument, -// flags as in runToday. -func runDate(args []string, cfg config.Config, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprintln(stderr, "lectio: date requires an argument (YYYY-MM-DD)") - return 2 - } - date := args[0] - if !dateRe.MatchString(date) { - fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", date) - return 2 - } - fs := flag.NewFlagSet("date", flag.ContinueOnError) - fs.SetOutput(stderr) - all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") - raw := fs.Bool("raw", false, "omit banner/headings (for piping)") - width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") - refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") - if err := fs.Parse(args[1:]); err != nil { - return 2 +// normalizeLectionary maps -l/--lectionary's accepted spellings ("new", +// "trad", "traditional") onto the canonical config.Config.Lectionary values +// ("new", "traditional"); "" (flag not given) passes through unchanged so +// the caller knows to leave config's own setting alone. +func normalizeLectionary(lectionary string) (string, error) { + switch lectionary { + case "": + return "", nil + case "trad": + return "traditional", nil + case "new", "traditional": + return lectionary, nil + default: + return "", fmt.Errorf("invalid --lectionary %q (want new|trad)", lectionary) } - return fetchAndPrint(cfg, cfg.DefaultVersion, date, *all, *raw, *width, *refresh, stdout, stderr) } -// runShow handles "show VERSION": same as today/date but the version is -// fixed by the caller instead of cfg.DefaultVersion. -func runShow(args []string, cfg config.Config, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprintln(stderr, "lectio: show requires a version argument (pl|wuj|vul|grb|drb)") - return 2 - } - ver := args[0] - if !validVersions[ver] { - fmt.Fprintf(stderr, "lectio: unknown version %q (want one of pl, wuj, vul, grb, drb)\n", ver) - return 2 - } - fs := flag.NewFlagSet("show", flag.ContinueOnError) - fs.SetOutput(stderr) - all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") - raw := fs.Bool("raw", false, "omit banner/headings (for piping)") - width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal)") - refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") - date := fs.String("date", today(), "date as YYYY-MM-DD") - if err := fs.Parse(args[1:]); err != nil { - return 2 - } - if !dateRe.MatchString(*date) { - fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date) - return 2 +// runHarvest handles -u/--update: harvest sigla maximally (to the +// unpublished horizon) from date, printing the outcome or -- on a genuine +// interruption (see liturgy.Harvest) -- the error. +func runHarvest(date string, stdout, stderr io.Writer) int { + added, furthest, err := liturgy.Harvest(date, 0) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 } - return fetchAndPrint(cfg, ver, *date, *all, *raw, *width, *refresh, stdout, stderr) + fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest) + return 0 } -// fetchAndPrint is the shared today/date/show path: fetch via the readings -// router, apply the offline version swap, then render each section's -// heading and render.GatherVersion blocks. +// fetchAndPrint is the shared single-version render path (default version or +// -b/--bible): fetch via the readings router, apply the offline version +// swap, then render each section's heading and render.GatherVersion blocks. func fetchAndPrint(cfg config.Config, version, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { secs, err := readings.Load(cfg, readings.Options{ Date: date, @@ -320,31 +315,10 @@ func renderSection(sec liturgy.Section, version, lectionary string, width int, r return strings.TrimRight(strings.Join(lines, "\n"), "\n") } -// runCompare handles "compare LIST": LIST is the required first positional -// argument (falling back to cfg.Versions when omitted entirely). -func runCompare(args []string, cfg config.Config, stdout, stderr io.Writer) int { - var list string - rest := args - if len(args) > 0 && !strings.HasPrefix(args[0], "-") { - list = args[0] - rest = args[1:] - } - - fs := flag.NewFlagSet("compare", flag.ContinueOnError) - fs.SetOutput(stderr) - all := fs.Bool("all", cfg.All, "show every reading part, not just the gospel") - raw := fs.Bool("raw", false, "omit banner/headings (for piping)") - width := fs.Int("width", cfg.Width, "wrap width (0 = detect terminal; compare default 120)") - refresh := fs.Bool("refresh", false, "bypass cache, re-fetch") - date := fs.String("date", today(), "date as YYYY-MM-DD") - if err := fs.Parse(rest); err != nil { - return 2 - } - if !dateRe.MatchString(*date) { - fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *date) - return 2 - } - +// renderCompare handles -c/--compare LIST: LIST is a comma-separated list of +// version codes (falling back to cfg.Versions when empty), rendered side by +// side via render.Compare. +func renderCompare(cfg config.Config, list, date string, all, raw bool, width int, refresh bool, stdout, stderr io.Writer) int { var versions []string if list == "" { versions = append(versions, cfg.Versions...) @@ -368,24 +342,24 @@ func runCompare(args []string, cfg config.Config, stdout, stderr io.Writer) int } secs, err := readings.Load(cfg, readings.Options{ - Date: *date, - Refresh: *refresh, + Date: date, + Refresh: refresh, Offline: cfg.Offline, - All: *all, + All: all, }) if err != nil { fmt.Fprintln(stderr, "lectio:", err) return 1 } if len(secs) == 0 { - fmt.Fprintln(stderr, "lectio: no readings found for", *date) + fmt.Fprintln(stderr, "lectio: no readings found for", date) return 1 } - w := resolveWidth(*width, true, stdout) + w := resolveWidth(width, true, stdout) - if !*raw { - banner := bannerFor(*all, *date) + if !raw { + banner := bannerFor(all, date) fmt.Fprintln(stdout, banner) fmt.Fprintln(stdout, strings.Repeat("=", minInt(len(banner), w))) fmt.Fprintln(stdout) @@ -394,30 +368,6 @@ func runCompare(args []string, cfg config.Config, stdout, stderr io.Writer) int return 0 } -// runUpdate handles "update [--days N] [--from D]": it does not touch -// config -- liturgy.Harvest owns its own cache/sigla-store paths. -func runUpdate(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("update", flag.ContinueOnError) - fs.SetOutput(stderr) - days := fs.Int("days", 0, "max days to harvest (0 = until the unpublished horizon)") - from := fs.String("from", today(), "start date as YYYY-MM-DD (default: today)") - if err := fs.Parse(args); err != nil { - return 2 - } - if !dateRe.MatchString(*from) { - fmt.Fprintf(stderr, "lectio: invalid date %q (want YYYY-MM-DD)\n", *from) - return 2 - } - - added, furthest, err := liturgy.Harvest(*from, *days) - if err != nil { - fmt.Fprintln(stderr, "lectio:", err) - return 1 - } - fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest) - return 0 -} - // resolveWidth applies the --width 0 = detect-terminal rule: an explicit // positive width wins, then a real terminal width if stdout is a tty, then // a sane per-mode default. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 85097ed..de9220a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -14,10 +14,18 @@ func TestHelp(t *testing.T) { } } -func TestUnknownCommand(t *testing.T) { +func TestHelpFlag(t *testing.T) { + var out, errb bytes.Buffer + code := Run([]string{"-h"}, nil, &out, &errb) + if code != 0 || !strings.Contains(out.String(), "lectio") { + t.Errorf("-h code=%d out=%q", code, out.String()) + } +} + +func TestUnexpectedPositional(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"bogus"}, nil, &out, &errb); code != 2 { - t.Errorf("unknown cmd code=%d want 2", code) + t.Errorf("unexpected positional code=%d want 2 (stderr=%q)", code, errb.String()) } } @@ -28,24 +36,110 @@ func TestVersion(t *testing.T) { } } -// TestBadDate exercises the "date" subcommand's usage-error path (bad -// YYYY-MM-DD format) without touching the network: format validation -// happens before any fetch is attempted. +// TestBadDate exercises the usage-error path for a malformed date: a token +// that doesn't match dateRe's YYYY-MM-DD shape is left as an unrecognised +// positional argument once flag parsing is done, which is a usage error. func TestBadDate(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer - if code := Run([]string{"date", "13-13-13"}, nil, &out, &errb); code != 2 { + if code := Run([]string{"13-13-13"}, nil, &out, &errb); code != 2 { t.Errorf("bad date code=%d want 2 (stderr=%q)", code, errb.String()) } } -// TestUnknownVersion exercises "show"'s version-validation usage-error path -// without touching the network: the version code is checked against the -// five known codes before any fetch is attempted. +// TestTwoDatesIsAmbiguous exercises extractDate's ambiguity check: two +// dateRe-shaped tokens can't both be the positional date. +func TestTwoDatesIsAmbiguous(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out, errb bytes.Buffer + if code := Run([]string{"2026-07-22", "2026-07-23"}, nil, &out, &errb); code != 2 { + t.Errorf("two dates code=%d want 2 (stderr=%q)", code, errb.String()) + } +} + +// TestUnknownVersion exercises -b/--bible's version-validation usage-error +// path without touching the network: the version code is checked against +// the five known codes before any fetch is attempted. func TestUnknownVersion(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) var out, errb bytes.Buffer - if code := Run([]string{"show", "zzz"}, nil, &out, &errb); code != 2 { + if code := Run([]string{"-b", "zzz"}, nil, &out, &errb); code != 2 { t.Errorf("unknown version code=%d want 2 (stderr=%q)", code, errb.String()) } } + +// TestLectionaryTradMapsToTraditional exercises -l/--lectionary's trad -> +// traditional normalisation. It goes through extractDate/flag parsing and +// validation only (no network): a bogus date lets it exit before any fetch +// is attempted, but only after the -l value has already been validated and +// mapped, which is what this test checks indirectly via the exit code (a +// bad --lectionary would exit 2 for a different reason, so this alone +// wouldn't distinguish the two -- see TestLectionaryBogus for that side). +// Exercised directly against parseFlags below. +func TestLectionaryTradMapsToTraditional(t *testing.T) { + lectionary, err := normalizeLectionary("trad") + if err != nil { + t.Fatalf("normalizeLectionary(trad): %v", err) + } + if lectionary != "traditional" { + t.Errorf("normalizeLectionary(trad) = %q, want traditional", lectionary) + } +} + +func TestLectionaryBogus(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out, errb bytes.Buffer + if code := Run([]string{"-l", "bogus"}, nil, &out, &errb); code != 2 { + t.Errorf("bogus lectionary code=%d want 2 (stderr=%q)", code, errb.String()) + } + if _, err := normalizeLectionary("bogus"); err == nil { + t.Error("normalizeLectionary(bogus): want error, got nil") + } +} + +func TestLangBogus(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out, errb bytes.Buffer + if code := Run([]string{"-g", "de"}, nil, &out, &errb); code != 2 { + t.Errorf("bogus lang code=%d want 2 (stderr=%q)", code, errb.String()) + } +} + +// TestDateTokenAnyPosition exercises extractDate directly: the date token +// is found regardless of where it appears among other flags. +func TestDateTokenAnyPosition(t *testing.T) { + cases := [][]string{ + {"-a", "2026-07-22"}, + {"2026-07-22", "-a"}, + {"-a", "2026-07-22", "-r"}, + } + for _, args := range cases { + date, rest, err := extractDate(args) + if err != nil { + t.Fatalf("extractDate(%v): %v", args, err) + } + if date != "2026-07-22" { + t.Errorf("extractDate(%v) date = %q, want 2026-07-22", args, date) + } + for _, r := range rest { + if r == "2026-07-22" { + t.Errorf("extractDate(%v) rest = %v still contains the date", args, rest) + } + } + } +} + +// TestExtractDateDefaultsToday checks that omitting a date token leaves +// today's date and passes every token through untouched. +func TestExtractDateDefaultsToday(t *testing.T) { + date, rest, err := extractDate([]string{"-a", "-r"}) + if err != nil { + t.Fatalf("extractDate: %v", err) + } + if date != today() { + t.Errorf("extractDate date = %q, want today() = %q", date, today()) + } + if len(rest) != 2 || rest[0] != "-a" || rest[1] != "-r" { + t.Errorf("extractDate rest = %v, want [-a -r]", rest) + } +} diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go index 6762585..cc176e8 100644 --- a/internal/liturgy/store.go +++ b/internal/liturgy/store.go @@ -9,6 +9,15 @@ import ( "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 { @@ -89,17 +98,23 @@ func writeSigla(path string, byDate map[string][]siglaRow) error { // 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. +// 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. +// 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 { @@ -114,16 +129,32 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro dir := cacheDir() day := start + var harvestErr error for i := 0; maxDays == 0 || i < maxDays; i++ { dateStr := day.Format("2006-01-02") - page, ferr := fetch(dateStr) + 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 { - break // unreachable site or network error: stop, not a hard failure + // 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 + break // unpublished horizon (or unparsable page): stop walking, cleanly } var rows []siglaRow @@ -148,8 +179,12 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro day = day.AddDate(0, 0, 1) } - if err := writeSigla(path, byDate); err != nil { - return added, furthest, err + werr := writeSigla(path, byDate) + if harvestErr != nil { + return added, furthest, harvestErr + } + if werr != nil { + return added, furthest, werr } return added, furthest, nil } diff --git a/internal/liturgy/store_test.go b/internal/liturgy/store_test.go index 08fd1a1..1bf8343 100644 --- a/internal/liturgy/store_test.go +++ b/internal/liturgy/store_test.go @@ -6,6 +6,7 @@ import ( "os" "strings" "testing" + "time" ) func TestHarvestAndOffline(t *testing.T) { @@ -47,3 +48,53 @@ func TestHarvestAndOffline(t *testing.T) { t.Error("offline first czytanie PartID missing") } } + +// TestHarvestFetchErrorSavesPartialProgress exercises the transient-error +// path: a genuine network/transport failure on a date must NOT be mistaken +// for the unpublished horizon (that is Parse's job, on a "Przykro nam" page +// -- see TestHarvestAndOffline above, which stays nil-error). It must +// instead surface as a returned error, after writeSigla has still saved +// whatever was harvested before the failing date. +func TestHarvestFetchErrorSavesPartialProgress(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) + return + } + // Simulate a network/transport error (not a "Przykro nam" horizon + // page) by hijacking the connection and closing it without a + // response, so the client sees a read/EOF error. + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("test server ResponseWriter does not support hijacking") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatal(err) + } + conn.Close() + })) + defer srv.Close() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + baseURL = srv.URL + "/liturgia/%s/Ewangelia" + + origRetries, origDelay := harvestRetries, harvestRetryDelay + harvestRetries, harvestRetryDelay = 2, time.Millisecond + defer func() { harvestRetries, harvestRetryDelay = origRetries, origDelay }() + + added, furthest, err := Harvest("2026-07-22", 0) + if err == nil { + t.Fatal("harvest: want error on a fetch failure, got nil") + } + if added != 1 || furthest != "2026-07-22" { + t.Errorf("harvest: added=%d furthest=%q, want added=1 furthest=2026-07-22 (only the one date fetched before the network error)", added, furthest) + } + + // Progress made before the failing date must still be on disk. + secs, offErr := LoadOffline("2026-07-22") + if offErr != nil || len(secs) == 0 { + t.Errorf("harvest: partial progress not saved: offErr=%v secs=%v", offErr, secs) + } +} |
