From f8c92f253693e269edbc76da9a99e65df7d6f699 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 21:37:47 +0200 Subject: feat(cli): --format json|ical calendar emitters with range + validation --- internal/cli/cli.go | 42 +++++++++++-- internal/cli/feed.go | 130 +++++++++++++++++++++++++++++++++++++++++ internal/cli/feed_test.go | 146 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 internal/cli/feed.go create mode 100644 internal/cli/feed_test.go (limited to 'internal/cli') diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 364e080..f65774f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -61,6 +61,11 @@ Flags: --pdf export the readings as PDF (to --out or stdout) --out FILE write --md/--pdf/--calendar output to FILE --calendar YYYY-MM export the month as a printable A4 PDF calendar + --format WHICH json|ical: emit the computed calendar and exit + --from DATE range start YYYY-MM-DD (with --to; needs --format) + --to DATE range end YYYY-MM-DD (with --from; needs --format) + --year YYYY emit the whole year YYYY (needs --format) + --form WHICH override calendar form old|new (needs --format) -v, --version print the version and exit -h, --help this help @@ -129,6 +134,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var calNew, calCheck string var corpusCheck string var jsonOut bool + var format, fromFlag, toFlag, yearFlag, formFlag string fs := flag.NewFlagSet("lectio", flag.ContinueOnError) fs.SetOutput(stderr) @@ -179,6 +185,11 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.StringVar(&calCheck, "cal-check", "", "validate the calendar-layer file NAME.ini and exit") fs.StringVar(&corpusCheck, "corpus-check", "", "validate a bible corpus (code or path to .tsv) and exit") fs.BoolVar(&jsonOut, "json", false, "with --corpus-check, print the report as JSON") + fs.StringVar(&format, "format", "", "json|ical: emit the computed calendar and exit") + fs.StringVar(&fromFlag, "from", "", "range start YYYY-MM-DD (with --to; needs --format)") + fs.StringVar(&toFlag, "to", "", "range end YYYY-MM-DD (with --from; needs --format)") + fs.StringVar(&yearFlag, "year", "", "emit the whole year YYYY (needs --format)") + fs.StringVar(&formFlag, "form", "", "override calendar form: old|new (needs --format)") if err := fs.Parse(rest); err != nil { return 2 @@ -235,6 +246,10 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { cfg.UILanguage = config.NormalizeUILanguage(uiLang) } + if format != "" || fromFlag != "" || toFlag != "" || yearFlag != "" || formFlag != "" { + return runFeed(cfg, format, date, fromFlag, toFlag, yearFlag, formFlag, stdout, stderr) + } + if liturgy { return runLiturgy(cfg, date, stdout, stderr) } @@ -553,15 +568,34 @@ func wantsVersion(args []string) bool { return false } +// rangeDateFlags are the flags whose own value is itself a YYYY-MM-DD date +// (--from/--to, in either one- or two-dash spelling -- Go's flag package +// treats them the same). extractDate must not mistake such a value for the +// positional DATE token. +var rangeDateFlags = map[string]bool{"--from": true, "-from": true, "--to": true, "-to": true} + // 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. +// remaining tokens for flag.FlagSet to parse. No other flag's own value can +// match dateRe's shape (width is an int, versions/lists/lang/lectionary are +// short codes) except --from/--to's, which are skipped explicitly via +// rangeDateFlags, 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 + skipNext := false for _, a := range args { + if skipNext { + rest = append(rest, a) + skipNext = false + continue + } + if rangeDateFlags[a] { + rest = append(rest, a) + skipNext = true + continue + } if dateRe.MatchString(a) { if found { return "", nil, fmt.Errorf("multiple dates given (%q and %q)", date, a) diff --git a/internal/cli/feed.go b/internal/cli/feed.go new file mode 100644 index 0000000..f7e7bab --- /dev/null +++ b/internal/cli/feed.go @@ -0,0 +1,130 @@ +package cli + +import ( + "fmt" + "io" + "strconv" + "time" + + "github.com/lukaszkasprzak/lectio/internal/caldata" + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/calfeed" + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// cliMaxSpanDays caps --from/--to at 100 years. The CLI is a local, trusted +// surface (unlike the web endpoints' tighter 1830-day cap), so this exists +// only to catch a fat-fingered year, not to bound resource use. +const cliMaxSpanDays = 36525 + +// minCalendarYear/maxCalendarYear bound --year to the domain the Gregorian +// Computus (Easter algorithm) is valid for; years before the 1582 reform are +// rejected. +const ( + minCalendarYear = 1583 + maxCalendarYear = 9999 +) + +// runFeed handles --format json|ical (+ --from/--to, --year, --form): builds +// the requested day range with the shared calfeed.Build/caldata.Readings +// pipeline (the same one --liturgy uses) and writes the rendered feed to +// stdout. date is the positional DATE (already defaulted to today by +// extractDate); it is used only when neither --from/--to nor --year is +// given. Every validation failure is a fixed stderr message and exit 2; +// nothing is echoed back beyond the offending flag's own value. +func runFeed(cfg config.Config, format, date, fromFlag, toFlag, yearFlag, formFlag string, stdout, stderr io.Writer) int { + if format == "" { + fmt.Fprintln(stderr, "lectio: --from/--to/--year/--form require --format json|ical") + return 2 + } + if format != "json" && format != "ical" { + fmt.Fprintf(stderr, "lectio: invalid --format %q (want json|ical)\n", format) + return 2 + } + + from, to, err := feedRange(date, fromFlag, toFlag, yearFlag) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 2 + } + + sel := cfg.Selection() + if formFlag != "" { + if formFlag != "old" && formFlag != "new" { + fmt.Fprintf(stderr, "lectio: invalid --form %q (want old|new)\n", formFlag) + return 2 + } + sel.Form = formFlag + } + + dir, _ := config.CalendarsDir() + layers, errs := caldata.Stack(sel.Form, dir, cfg.Use) + for _, e := range errs { + fmt.Fprintln(stderr, "lectio: warning:", e) + } + + days := calfeed.Build(from, to, cfg.UILanguage, sel, layers, func(d time.Time, day calendar.LiturgicalDay) []calendar.Reading { + return caldata.Readings(sel, layers, d, day) + }) + + var out []byte + switch format { + case "json": + out, err = calfeed.JSON(sel.Form, days) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + case "ical": + out = calfeed.ICal(sel.Form, days, time.Now()) + } + _, _ = stdout.Write(out) + return 0 +} + +// feedRange resolves --from/--to/--year (mutually exclusive) or, absent +// both, dateStr (the positional DATE) into an inclusive [from, to] range. +// Dates are parsed strictly with time.Parse("2006-01-02", …); --year is +// bounded to [minCalendarYear, maxCalendarYear]; from must not be after to; +// the span is capped at cliMaxSpanDays. Every branch returns a fixed, +// input-scoped error message. +func feedRange(dateStr, fromStr, toStr, yearStr string) (from, to time.Time, err error) { + switch { + case yearStr != "" && (fromStr != "" || toStr != ""): + return time.Time{}, time.Time{}, fmt.Errorf("--year cannot be combined with --from/--to") + case fromStr != "" && toStr == "": + return time.Time{}, time.Time{}, fmt.Errorf("--from requires --to") + case toStr != "" && fromStr == "": + return time.Time{}, time.Time{}, fmt.Errorf("--to requires --from") + case yearStr != "": + year, cerr := strconv.Atoi(yearStr) + if cerr != nil || year < minCalendarYear || year > maxCalendarYear { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --year %q (want an integer %d-%d)", yearStr, minCalendarYear, maxCalendarYear) + } + from = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + to = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC) + return from, to, nil + case fromStr != "" && toStr != "": + from, err = time.Parse("2006-01-02", fromStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --from %q (want YYYY-MM-DD)", fromStr) + } + to, err = time.Parse("2006-01-02", toStr) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --to %q (want YYYY-MM-DD)", toStr) + } + if to.Before(from) { + return time.Time{}, time.Time{}, fmt.Errorf("--from %s is after --to %s", fromStr, toStr) + } + if days := int(to.Sub(from).Hours()/24) + 1; days > cliMaxSpanDays { + return time.Time{}, time.Time{}, fmt.Errorf("range too large (%d days; max %d)", days, cliMaxSpanDays) + } + return from, to, nil + default: + d, derr := time.Parse("2006-01-02", dateStr) + if derr != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid date %q (want YYYY-MM-DD)", dateStr) + } + return d, d, nil + } +} diff --git a/internal/cli/feed_test.go b/internal/cli/feed_test.go new file mode 100644 index 0000000..efbcfc9 --- /dev/null +++ b/internal/cli/feed_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// feedTestConfig points LECTIO_CONFIG at a fresh, non-existent file in a +// temp dir so each test gets lectio's built-in defaults (Universal Roman +// Calendar, no user layers) regardless of the machine's real config. +func feedTestConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) +} + +func TestFeedJSONRange(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "json", "--from", "2026-01-01", "--to", "2026-01-03"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("exit code %d (stderr=%q)", code, errb.String()) + } + + var payload struct { + Schema string `json:"schema"` + Days []struct { + Date string `json:"date"` + } `json:"days"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("stdout did not parse as JSON: %v\n%s", err, out.String()) + } + if payload.Schema != "lectio.calendar/1" { + t.Errorf("schema = %q, want lectio.calendar/1", payload.Schema) + } + if len(payload.Days) != 3 { + t.Errorf("len(days) = %d, want 3", len(payload.Days)) + } +} + +func TestFeedICalYear(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "ical", "--year", "2026"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("exit code %d (stderr=%q)", code, errb.String()) + } + + got := out.String() + if !strings.HasPrefix(got, "BEGIN:VCALENDAR") { + end := len(got) + if end > 80 { + end = 80 + } + t.Fatalf("output does not start with BEGIN:VCALENDAR: %q", got[:end]) + } + if n := strings.Count(got, "BEGIN:VEVENT"); n != 365 { + t.Errorf("BEGIN:VEVENT count = %d, want 365 (2026 is not a leap year)", n) + } +} + +func TestFeedInvertedRangeIsUsageError(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "json", "--from", "2026-01-05", "--to", "2026-01-01"}, nil, &out, &errb) + if code != 2 { + t.Fatalf("exit code %d, want 2 (stdout=%q stderr=%q)", code, out.String(), errb.String()) + } +} + +func TestFeedYearOutOfDomainIsUsageError(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "json", "--year", "1500"}, nil, &out, &errb) + if code != 2 { + t.Fatalf("exit code %d, want 2 (stdout=%q stderr=%q)", code, out.String(), errb.String()) + } +} + +// TestFeedSingleDayDefaultsToday exercises --format with no range flags at +// all: the positional DATE (given explicitly here) is used as a single-day +// range. +func TestFeedSingleDayFromPositionalDate(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"2026-01-06", "--format", "json"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("exit code %d (stderr=%q)", code, errb.String()) + } + var payload struct { + Days []struct { + Date string `json:"date"` + } `json:"days"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("stdout did not parse as JSON: %v\n%s", err, out.String()) + } + if len(payload.Days) != 1 || payload.Days[0].Date != "2026-01-06" { + t.Errorf("days = %+v, want a single 2026-01-06", payload.Days) + } +} + +// TestFeedFromWithoutFormatIsUsageError: --from/--to/--year/--form only mean +// anything alongside --format; given alone they are a usage error rather +// than silently ignored. +func TestFeedFromWithoutFormatIsUsageError(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--from", "2026-01-01", "--to", "2026-01-03"}, nil, &out, &errb) + if code != 2 { + t.Fatalf("exit code %d, want 2 (stdout=%q stderr=%q)", code, out.String(), errb.String()) + } +} + +// TestFeedFormOverridesSelection: --form old switches the built calendar to +// the Extraordinary Form even when config defaults to the OF (new). +func TestFeedFormOverridesSelection(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "json", "--form", "old", "2026-01-06"}, nil, &out, &errb) + if code != 0 { + t.Fatalf("exit code %d (stderr=%q)", code, errb.String()) + } + var payload struct { + Form string `json:"form"` + } + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("stdout did not parse as JSON: %v\n%s", err, out.String()) + } + if payload.Form != "old" { + t.Errorf("form = %q, want old", payload.Form) + } +} + +func TestFeedBadFormatIsUsageError(t *testing.T) { + feedTestConfig(t) + var out, errb bytes.Buffer + code := Run([]string{"--format", "xml"}, nil, &out, &errb) + if code != 2 { + t.Fatalf("exit code %d, want 2 (stdout=%q stderr=%q)", code, out.String(), errb.String()) + } +} -- cgit v1.3