diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 13:23:51 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 13:23:51 +0200 |
| commit | e91a7dadb4401a553ffdd4f3661640921e425eb6 (patch) | |
| tree | 94df3ed52204c186c505a19e1efc650895edf795 /internal | |
| parent | fb3fdc69fad0f374c419559d99d75ba8c81aa632 (diff) | |
| download | lectio-e91a7dadb4401a553ffdd4f3661640921e425eb6.tar.gz lectio-e91a7dadb4401a553ffdd4f3661640921e425eb6.zip | |
feat(cli): --cal-new scaffold + --cal-check lint for custom calendar layers
Also: reject out-of-range MM-DD in resolveDate (strict validation for user data).
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/calendar/datespec.go | 9 | ||||
| -rw-r--r-- | internal/cli/callayer.go | 136 | ||||
| -rw-r--r-- | internal/cli/callayer_test.go | 57 | ||||
| -rw-r--r-- | internal/cli/cli.go | 11 |
4 files changed, 212 insertions, 1 deletions
diff --git a/internal/calendar/datespec.go b/internal/calendar/datespec.go index d0040cc..c04bc14 100644 --- a/internal/calendar/datespec.go +++ b/internal/calendar/datespec.go @@ -6,6 +6,13 @@ import ( "time" ) +// ValidDate reports whether spec resolves to a date (validated against a sample +// year). Used by the `--cal-check` layer linter. +func ValidDate(spec DateSpec) bool { + _, ok := resolveDate(spec, 2025, Easter(2025)) + return ok +} + // resolveDate returns the date (UTC midnight) that spec names in year, or // ok=false if the spec is unparseable. func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool) { @@ -14,7 +21,7 @@ func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool) { case len(s) == 5 && s[2] == '-': // MM-DD mo, err1 := strconv.Atoi(s[0:2]) da, err2 := strconv.Atoi(s[3:5]) - if err1 != nil || err2 != nil { + if err1 != nil || err2 != nil || mo < 1 || mo > 12 || da < 1 || da > 31 { return time.Time{}, false } return time.Date(year, time.Month(mo), da, 0, 0, 0, 0, time.UTC), true diff --git a/internal/cli/callayer.go b/internal/cli/callayer.go new file mode 100644 index 0000000..3900eb1 --- /dev/null +++ b/internal/cli/callayer.go @@ -0,0 +1,136 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/lukaszkasprzak/lectio/internal/caldata" + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// calLayerTemplate is the scaffold written by --cal-new (%s = the layer name). +const calLayerTemplate = `# lectio calendar layer "%[1]s". Add celebrations below, then enable this file +# by adding its name to 'use' in config.ini: use = %[1]s +# Layers stack over the universal calendar: a [slug] merges over the same slug in +# a lower layer (field by field); a new [slug] adds a celebration. +# +# Fields: +# date MM-DD, or easter±N / christmas±N / sunday-after MM-DD +# rank solemnity | feast | memorial | optional +# class lord | bvm | saint (precedence tiebreak) +# colour white | red | green | violet | rose | black +# name.<lang> e.g. name.en, name.pl, name.la +# reading.<part> part = first | psalm | second | acclamation | gospel (a citation) +# suppress = true remove a celebration inherited from a lower layer + +[layer] +id = %[1]s +name = %[1]s +type = diocesan + +# Example (edit or delete): +[st-example] +date = 06-15 +rank = solemnity +class = saint +colour = white +name.en = Saint Example, Patron +reading.gospel = Jn 10:11-16 +` + +var validRanks = map[string]bool{"solemnity": true, "feast": true, "memorial": true, "optional": true} + +// layerPath resolves <CalendarsDir>/<name>.ini, rejecting a name with path separators. +func layerPath(name string) (string, error) { + if name == "" || strings.ContainsAny(name, `/\`) { + return "", fmt.Errorf("invalid layer name %q", name) + } + dir, err := config.CalendarsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, name+".ini"), nil +} + +// runCalNew scaffolds a new calendar-layer file and exits. It refuses to +// overwrite an existing file. +func runCalNew(name string, stdout, stderr io.Writer) int { + path, err := layerPath(name) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 2 + } + if _, err := os.Stat(path); err == nil { + fmt.Fprintf(stderr, "lectio: %s already exists (not overwriting)\n", path) + return 1 + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(calLayerTemplate, name)), 0o644); err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + fmt.Fprintf(stdout, "created %s\n", path) + fmt.Fprintf(stdout, "edit it, then set use = %s in config.ini\n", name) + return 0 +} + +// runCalCheck validates a calendar-layer file (dates resolve, ranks/colours/ +// classes are known) and exits non-zero if it finds problems. +func runCalCheck(name string, stdout, stderr io.Writer) int { + path, err := layerPath(name) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 2 + } + layer, err := caldata.LoadLayer(path, name) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + slugs := make([]string, 0, len(layer.Cels)) + for s := range layer.Cels { + slugs = append(slugs, s) + } + sort.Strings(slugs) + + problems := 0 + report := func(slug, msg string) { + problems++ + fmt.Fprintf(stdout, " [%s] %s\n", slug, msg) + } + for _, slug := range slugs { + f := layer.Cels[slug].Fields + if f["suppress"] == "true" { + continue // a removal needs no other fields + } + switch d := f["date"]; { + case d == "": + report(slug, "missing date") + case !calendar.ValidDate(calendar.DateSpec(d)): + report(slug, fmt.Sprintf("unparseable date %q", d)) + } + if r := f["rank"]; r != "" && !validRanks[r] { + report(slug, fmt.Sprintf("unknown rank %q (want solemnity|feast|memorial|optional)", r)) + } + if c := f["colour"]; c != "" && calendar.ParseColour(c) == "" { + report(slug, fmt.Sprintf("unknown colour %q", c)) + } + if cl := f["class"]; cl != "" && cl != "lord" && cl != "bvm" && cl != "saint" { + report(slug, fmt.Sprintf("unknown class %q (want lord|bvm|saint)", cl)) + } + } + if problems > 0 { + fmt.Fprintf(stderr, "lectio: %s: %d problem(s)\n", path, problems) + return 1 + } + fmt.Fprintf(stdout, "ok: %s (%d celebrations)\n", path, len(layer.Cels)) + return 0 +} diff --git a/internal/cli/callayer_test.go b/internal/cli/callayer_test.go new file mode 100644 index 0000000..4803fdf --- /dev/null +++ b/internal/cli/callayer_test.go @@ -0,0 +1,57 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunCalNewAndCheck(t *testing.T) { + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) + + var buf bytes.Buffer + if code := runCalNew("krakow", &buf, &buf); code != 0 { + t.Fatalf("cal-new exit %d: %s", code, buf.String()) + } + path := filepath.Join(dir, "calendars", "krakow.ini") + if _, err := os.Stat(path); err != nil { + t.Fatal("scaffold not written") + } + + // the scaffold must validate clean + buf.Reset() + if code := runCalCheck("krakow", &buf, &buf); code != 0 { + t.Fatalf("cal-check on scaffold exit %d: %s", code, buf.String()) + } + if !strings.Contains(buf.String(), "ok:") { + t.Errorf("expected ok, got: %s", buf.String()) + } + + // refuse to overwrite + buf.Reset() + if code := runCalNew("krakow", &buf, &buf); code == 0 { + t.Error("cal-new should refuse to overwrite an existing file") + } +} + +func TestRunCalCheckReportsBadFields(t *testing.T) { + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) + os.MkdirAll(filepath.Join(dir, "calendars"), 0o755) + os.WriteFile(filepath.Join(dir, "calendars", "bad.ini"), + []byte("[layer]\nid = bad\n\n[x]\ndate = 99-99\nrank = archfeast\ncolour = teal\n"), 0o644) + + var buf bytes.Buffer + if code := runCalCheck("bad", &buf, &buf); code == 0 { + t.Fatalf("cal-check should fail on bad fields:\n%s", buf.String()) + } + out := buf.String() + for _, want := range []string{"date", "rank", "colour"} { + if !strings.Contains(out, want) { + t.Errorf("expected a %s problem reported:\n%s", want, out) + } + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 51a5a4b..575fba9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -49,6 +49,8 @@ Flags: --citation print the day's gospel reference (scripts/cron) and exit --week list the coming week's gospel references and exit -L, --liturgy print the computed liturgical day (offline, no network) and exit + --cal-new NAME scaffold a custom calendar layer ~/.config/lectio/calendars/NAME.ini + --cal-check NAME validate a custom calendar layer NAME.ini and exit --rand, --rand-v print a random verse and exit (uses default_version or -b VER; bt has no corpus, so it falls back to a corpus version) --rand-ch print a random chapter and exit @@ -121,6 +123,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { var list bool var ref string var liturgy bool + var calNew, calCheck string fs := flag.NewFlagSet("lectio", flag.ContinueOnError) fs.SetOutput(stderr) @@ -167,6 +170,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.StringVar(&calendar, "calendar", "", "export a month (YYYY-MM) as a printable A4 PDF calendar") fs.BoolVar(&liturgy, "L", false, "print the computed liturgical day (offline calendar engine) and exit") fs.BoolVar(&liturgy, "liturgy", false, "print the computed liturgical day (offline calendar engine) and exit") + fs.StringVar(&calNew, "cal-new", "", "scaffold a new calendar-layer file NAME.ini and exit") + fs.StringVar(&calCheck, "cal-check", "", "validate the calendar-layer file NAME.ini and exit") if err := fs.Parse(rest); err != nil { return 2 @@ -192,6 +197,12 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if update { return runHarvest(date, stdout, stderr) } + if calNew != "" { + return runCalNew(calNew, stdout, stderr) + } + if calCheck != "" { + return runCalCheck(calCheck, stdout, stderr) + } cfg, err := config.Load() if err != nil { |
