From acbba6b53df93de694abf1feb90ab401fd6a4580 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 13:19:29 +0200 Subject: feat(caldata): export ParseLayer + LoadLayer + Stack (user calendar layers) --- .../2026-07-27-lectio-calendar-customization.md | 54 +++++++++++++++++++++ internal/caldata/caldata.go | 43 ++++++++++++++++- internal/caldata/stack_test.go | 56 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-27-lectio-calendar-customization.md create mode 100644 internal/caldata/stack_test.go diff --git a/docs/superpowers/plans/2026-07-27-lectio-calendar-customization.md b/docs/superpowers/plans/2026-07-27-lectio-calendar-customization.md new file mode 100644 index 0000000..1b4f725 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-lectio-calendar-customization.md @@ -0,0 +1,54 @@ +# Calendar Customization (User Layer Files) Implementation Plan — Sub-project #2 + +> REQUIRED SUB-SKILL: superpowers:executing-plans. Design settled in brainstorming; format documented in the #1 spec. + +**Goal:** Let users add/override the liturgical calendar with hand-edited INI layer files (`~/.config/lectio/calendars/*.ini`), stacked over the universal base via a config `use = a, b, c`, and provide `--cal-new`/`--cal-check` tooling. + +**Architecture:** Reuse the engine's existing ordered-layer merge (`calendar.Compute(date, sel, layers)`). `caldata` gains an exported layer parser + a `Stack(dir, use)` that returns `[Universal(), ...user layers]`. `config` gains a `Use []string` field and `CalendarsDir()`. The CLI wires the stack into `--liturgy` and adds scaffold/lint flags. + +**Tech Stack:** Go stdlib + internal/{calendar,ini,caldata,config,cli}. + +## Global Constraints +- `calendar` stays stdlib-pure. `caldata` may import calendar + ini (+ os/filepath for file loading). +- Layers are matched by **filename stem**: `use = krakow` loads `krakow.ini`. The `[layer]` header is metadata. +- Merge order = universal first, then `use` order (later wins), per the engine. +- A missing/invalid user layer warns and is skipped — it never breaks `--liturgy`. +- Formats: INI only (`use` is a comma list). + +--- + +### Task 1: Export layer parser + user-layer loading (`caldata`) +**Files:** Modify `internal/caldata/caldata.go`; Test `internal/caldata/stack_test.go` +**Produces:** `func ParseLayer(data []byte) (calendar.Layer, error)` (renamed from `parse`); `func LoadLayer(path, id string) (calendar.Layer, error)`; `func Stack(dir string, use []string) ([]calendar.Layer, []error)`. + +- [ ] Rename `parse` → `ParseLayer` (exported); `Universal()` calls it. +- [ ] Add `LoadLayer(path, id)`: read file, `ParseLayer`, set `Layer.ID = id` if the file omitted it. +- [ ] Add `Stack(dir, use)`: `[]calendar.Layer{Universal()}` + for each id in `use`, `LoadLayer(filepath.Join(dir, id+".ini"), id)`; collect per-id errors (skip the failed layer). +- [ ] Test: write two temp layer files; `Stack(dir, []string{"a","b"})` returns 3 layers in order; a missing id yields an error but the rest load. + +### Task 2: config `Use` + `CalendarsDir()` +**Files:** Modify `internal/config/config.go`; Test `internal/config/config_test.go` +**Produces:** `Config.Use []string`; `func CalendarsDir() (string, error)`; INI `use` key (top-level) read + written + documented in `configHeader`. + +- [ ] Add `Use []string` field (toml:"-"; INI-only). `applyScalar` case `"use": cfg.Use = ini.List(val)`. `renderConfigINI` writes `use = `. Add a `use` line to `configHeader`. +- [ ] `CalendarsDir()`: `filepath.Join(filepath.Dir(configPath), "calendars")`. +- [ ] Test: INI with `use = poland, krakow` loads `Use == ["poland","krakow"]`; round-trips through Save. + +### Task 3: Wire the stack into `--liturgy` +**Files:** Modify `internal/cli/liturgy.go`; Test `internal/cli/liturgy_test.go` +**Consumes:** `caldata.Stack`, `config.CalendarsDir`, `cfg.Use`. + +- [ ] In `runLiturgy`, build `layers` via `caldata.Stack(dir, cfg.Use)`; print a stderr warning per load error; pass `layers` to `Compute`. +- [ ] Test: a temp calendars dir with a `local.ini` adding a solemnity on a ferial date + `use = local` → `runLiturgy` shows that solemnity as observed. + +### Task 4: `--cal-new NAME` scaffold + `--cal-check NAME` lint +**Files:** Create `internal/cli/callayer.go`; Modify `internal/cli/cli.go` (flags + dispatch); Test `internal/cli/callayer_test.go` +**Produces:** `func runCalNew(cfg, name, stdout, stderr) int`; `func runCalCheck(cfg, name, stdout, stderr) int`. + +- [ ] `--cal-new NAME`: write `/NAME.ini` (a commented `[layer]` header + one example celebration); refuse to overwrite an existing file. +- [ ] `--cal-check NAME`: `LoadLayer` the file; for each celebration, verify the date resolves and rank parses (non-ferial); report problems or "ok". +- [ ] Flags `--cal-new`/`--cal-check` (string) in `Run`, dispatched after config load; add to help text. +- [ ] Tests: `runCalNew` creates a parseable file; a second call refuses; `runCalCheck` flags a bad date and passes a good file. + +### Task 5: Version bump + verification +- [ ] `config.Version` → `0.27.0`; `go build/vet/test ./...`; purity check unchanged. diff --git a/internal/caldata/caldata.go b/internal/caldata/caldata.go index 5deb895..9272fbb 100644 --- a/internal/caldata/caldata.go +++ b/internal/caldata/caldata.go @@ -6,6 +6,8 @@ package caldata import ( _ "embed" "fmt" + "os" + "path/filepath" "github.com/lukaszkasprzak/lectio/internal/calendar" "github.com/lukaszkasprzak/lectio/internal/ini" @@ -17,14 +19,51 @@ var romanCalendar []byte // Universal parses the embedded universal calendar. It panics on a malformed // embedded file (a build-time bug caught by tests), never at the request layer. func Universal() calendar.Layer { - l, err := parse(romanCalendar) + l, err := ParseLayer(romanCalendar) if err != nil { panic(fmt.Sprintf("caldata: embedded roman-calendar.ini invalid: %v", err)) } return l } -func parse(data []byte) (calendar.Layer, error) { +// LoadLayer reads and parses a user calendar layer file. id (the filename stem) +// is used as the layer's ID when the file's [layer] header omits one. +func LoadLayer(path, id string) (calendar.Layer, error) { + data, err := os.ReadFile(path) + if err != nil { + return calendar.Layer{}, err + } + l, err := ParseLayer(data) + if err != nil { + return calendar.Layer{}, fmt.Errorf("%s: %w", path, err) + } + if l.ID == "" { + l.ID = id + } + return l, nil +} + +// Stack returns the ordered layer stack for the engine: the universal base +// first, then each user layer named in use (matched to "/.ini"), in +// order. A layer that fails to load is skipped and reported in the error slice +// so a single bad file never breaks calendar computation. +func Stack(dir string, use []string) ([]calendar.Layer, []error) { + layers := []calendar.Layer{Universal()} + var errs []error + for _, id := range use { + l, err := LoadLayer(filepath.Join(dir, id+".ini"), id) + if err != nil { + errs = append(errs, err) + continue + } + layers = append(layers, l) + } + return layers, errs +} + +// ParseLayer parses INI bytes (a [layer] header + [slug]/[slug/variant] +// celebration sections) into a calendar.Layer. +func ParseLayer(data []byte) (calendar.Layer, error) { secs, err := ini.Parse(data) if err != nil { return calendar.Layer{}, err diff --git a/internal/caldata/stack_test.go b/internal/caldata/stack_test.go new file mode 100644 index 0000000..b9d5bfb --- /dev/null +++ b/internal/caldata/stack_test.go @@ -0,0 +1,56 @@ +package caldata + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStack(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "poland.ini"), + []byte("[layer]\nid = poland\ntype = national\n\n[our-lady-czestochowa]\ndate = 08-26\nrank = solemnity\nclass = bvm\ncolour = white\nname.pl = NMP Częstochowskiej\n"), 0o644) + os.WriteFile(filepath.Join(dir, "krakow.ini"), + []byte("[layer]\nid = krakow\ntype = diocesan\n\n[st-stanislaus]\ndate = 05-08\nrank = solemnity\nclass = saint\ncolour = red\nname.pl = Św. Stanisława\n"), 0o644) + + layers, errs := Stack(dir, []string{"poland", "krakow"}) + if len(errs) != 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if len(layers) != 3 { + t.Fatalf("want 3 layers (universal+2), got %d", len(layers)) + } + if layers[0].Type != "universal" || layers[1].ID != "poland" || layers[2].ID != "krakow" { + t.Errorf("order wrong: %q %q %q", layers[0].Type, layers[1].ID, layers[2].ID) + } + if _, ok := layers[1].Cels["our-lady-czestochowa"]; !ok { + t.Error("poland layer missing its celebration") + } +} + +func TestStackMissingLayerSkips(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "good.ini"), + []byte("[layer]\nid = good\n\n[x]\ndate = 01-02\nrank = memorial\nname.en = X\n"), 0o644) + layers, errs := Stack(dir, []string{"good", "missing"}) + if len(errs) != 1 { + t.Fatalf("want 1 error for the missing layer, got %v", errs) + } + if len(layers) != 2 { // universal + good; missing skipped + t.Fatalf("want 2 layers, got %d", len(layers)) + } +} + +func TestLoadLayerDefaultsIDToStem(t *testing.T) { + dir := t.TempDir() + // no id in the [layer] header -> defaults to the stem + os.WriteFile(filepath.Join(dir, "myparish.ini"), + []byte("[layer]\ntype = particular\n\n[dedication]\ndate = 06-15\nrank = solemnity\nname.en = Dedication\n"), 0o644) + l, err := LoadLayer(filepath.Join(dir, "myparish.ini"), "myparish") + if err != nil { + t.Fatal(err) + } + if l.ID != "myparish" { + t.Errorf("ID = %q, want myparish (from stem)", l.ID) + } +} -- cgit v1.3