aboutsummaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli.go42
-rw-r--r--internal/cli/feed.go130
-rw-r--r--internal/cli/feed_test.go146
3 files changed, 314 insertions, 4 deletions
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 <code>.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())
+ }
+}