summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 13:23:51 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 13:23:51 +0200
commite91a7dadb4401a553ffdd4f3661640921e425eb6 (patch)
tree94df3ed52204c186c505a19e1efc650895edf795 /internal
parentfb3fdc69fad0f374c419559d99d75ba8c81aa632 (diff)
downloadlectio-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.go9
-rw-r--r--internal/cli/callayer.go136
-rw-r--r--internal/cli/callayer_test.go57
-rw-r--r--internal/cli/cli.go11
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 {