aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go374
-rw-r--r--internal/cli/cli_test.go114
-rw-r--r--internal/liturgy/store.go55
-rw-r--r--internal/liturgy/store_test.go51
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)
+ }
+}