# lectio Go Rewrite Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Reimplement the Python `daily-reading` tool as a self-contained Go project with two binaries — `lectio` (subcommand CLI) and `lectio-ui` (colored Bubble Tea TUI) — that fetches the daily Catholic readings and shows them across five versions with correct psalm versification, works offline from a harvested sigla file, and needs no external tools. **Architecture:** Thin front-ends (`cmd/lectio`, `cmd/lectio-ui`) over shared `internal/` packages: `bible` (embedded corpora + lookup + citation conversion), `psalter` (versification), `liturgy` (fetch/parse/cache/harvest/offline), `config`, `render` (text output), `cli`, `tui`. Modelled on `~/git/projects/bread-calc`. **Tech Stack:** Go 1.24, `github.com/charmbracelet/bubbletea` + `lipgloss` (TUI), `github.com/pelletier/go-toml/v2` (config). Stdlib `flag`, `net/http`, `embed`, `encoding/json` elsewhere. **Spec:** `docs/superpowers/specs/2026-07-23-lectio-go-rewrite-design.md` (read it before starting). ## Global Constraints - Module path: `github.com/lukaszkasprzak/lectio`. Go directive: `go 1.24.0`. - No external runtime tools (no `vul`/`grb`/`wuj`/`drb`); all four corpora are embedded. - No CLI framework dependency (cobra etc.) — dispatch hand-rolled with stdlib `flag`. - Five version codes: `pl, wuj, vul, grb, drb`. Labels: `pl="Polski (niedziela.pl)"`, `wuj="Wujek (pol.)"`, `vul="Wulgata (lac.)"`, `grb="Grecki"`, `drb="Douay-Rheims (ang.)"`. - Psalm systems: `vul/grb/wuj` → `"vulgate"`, `drb` → `"drb"`. `pl` is not a bible-lookup version. - Cache dir: `${XDG_CACHE_HOME:-~/.cache}/lectio/`. Data dir (sigla): `${XDG_DATA_HOME:-~/.local/share}/lectio/sigla.tsv`. Config: `${XDG_CONFIG_HOME:-~/.config}/lectio/config.toml`. - Source URL: `https://niezbednik.niedziela.pl/liturgia/{date}/Ewangelia`. Dates are `YYYY-MM-DD`. - User-Agent: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36`. - Offline: `pl` is dropped and `wuj` takes the Polish role. - Exit codes: 0 ok, 1 runtime error, 2 usage error. - `gofmt`-formatted; `go vet ./...` clean. Commit after every task. --- ## File Structure ``` go.mod go.sum Makefile .gitignore README.md LICENSE cmd/lectio/main.go -> cli.Run(os.Args[1:], stdin, stdout, stderr) cmd/lectio-ui/main.go -> load config, tui.New(...), tea program internal/psalter/psalter.go DRB_TITLE_FOLD, DrbVerse, HebrewToVulgateChapter internal/bible/corpora/*.tsv embedded wuj.tsv vul.tsv grb.tsv drb.tsv internal/bible/bible.go Corpus, Load, Lookup, Verse internal/bible/books.go canonical books, alias table, ResolveBook internal/bible/ref.go ParseRef, SplitRef (reference grammar) internal/bible/convert.go ToEnglishRef, psalmRef (Polish citation -> English) internal/liturgy/section.go Section type, version constants/labels internal/liturgy/parse.go Parse(html) []Section internal/liturgy/fetch.go Fetch, cache (HTML+JSON), Load internal/liturgy/store.go Harvest (update), sigla TSV, offline Load internal/liturgy/testdata/*.html fixtures internal/config/config.go Config, Load, seed internal/render/render.go GatherVersion, Compare, RenderSection, dedup internal/cli/cli.go Run + subcommand dispatch internal/tui/styles.go color role styles internal/tui/tui.go Model, Init, Update, View ``` --- ## Task 1: Project scaffold + vendored corpora **Files:** - Create: `go.mod`, `Makefile`, `.gitignore`, `LICENSE`, `cmd/lectio/main.go`, `cmd/lectio-ui/main.go` - Create: `internal/bible/corpora/wuj.tsv`, `vul.tsv`, `grb.tsv`, `drb.tsv` **Interfaces:** - Produces: buildable module; `internal/cli.Run` and `internal/tui` referenced by mains (stubbed this task). - [ ] **Step 1: Init module and vendor corpora** ```bash cd ~/git/projects/lectio cat > go.mod <<'EOF' module github.com/lukaszkasprzak/lectio go 1.24.0 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/pelletier/go-toml/v2 v2.4.2 ) EOF mkdir -p internal/bible/corpora cmd/lectio cmd/lectio-ui cp ~/git/projects/offline_readings/corpus/wuj.tsv internal/bible/corpora/wuj.tsv cp ~/git/projects/offline_readings/drb/drb.tsv internal/bible/corpora/drb.tsv sed '1,/^#EOF$/d' "$(command -v vul)" | tar xzf - -O vul.tsv > internal/bible/corpora/vul.tsv sed '1,/^#EOF$/d' "$(command -v grb)" | tar xzf - -O grb.tsv > internal/bible/corpora/grb.tsv # grb.tsv has a UTF-8 BOM on line 1; strip it sed -i '1s/^\xEF\xBB\xBF//' internal/bible/corpora/grb.tsv wc -l internal/bible/corpora/*.tsv ``` Expected: four files, ~31k–35k lines each. - [ ] **Step 2: Stub the two mains so the tree builds** `cmd/lectio/main.go`: ```go package main import ( "os" "github.com/lukaszkasprzak/lectio/internal/cli" ) func main() { os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } ``` `internal/cli/cli.go` (temporary stub, replaced in Task 12): ```go package cli import ( "fmt" "io" ) // Run is the CLI entry point; returns a process exit code. func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "lectio: not yet implemented") return 0 } ``` `cmd/lectio-ui/main.go` (temporary stub, replaced in Task 15): ```go package main import "fmt" func main() { fmt.Println("lectio-ui: not yet implemented") } ``` - [ ] **Step 3: Makefile, .gitignore, LICENSE** `Makefile` (adapt bread-calc's): ```make LECTIO := lectio LECTIO_UI := lectio-ui PREFIX ?= $(HOME)/.local BINDIR := $(PREFIX)/bin .PHONY: help build install uninstall test vet fmt clean cross help: ## show this help @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | sort build: ## build ./lectio and ./lectio-ui go build -o $(LECTIO) ./cmd/lectio go build -o $(LECTIO_UI) ./cmd/lectio-ui install: ## build and install both to $(BINDIR) @mkdir -p $(BINDIR) go build -o $(BINDIR)/$(LECTIO) ./cmd/lectio go build -o $(BINDIR)/$(LECTIO_UI) ./cmd/lectio-ui @echo "installed $(BINDIR)/$(LECTIO) and $(BINDIR)/$(LECTIO_UI)" uninstall: ## remove installed binaries rm -f $(BINDIR)/$(LECTIO) $(BINDIR)/$(LECTIO_UI) test: ## run tests go test ./... vet: ## go vet go vet ./... fmt: ## gofmt the tree gofmt -w . clean: ## remove build artifacts rm -f $(LECTIO) $(LECTIO_UI); rm -rf dist cross: ## cross-compile into dist/ @mkdir -p dist @for t in linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64; do \ os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \ echo " $$os/$$arch"; \ GOOS=$$os GOARCH=$$arch go build -o dist/$(LECTIO)-$$os-$$arch$$ext ./cmd/lectio; \ GOOS=$$os GOARCH=$$arch go build -o dist/$(LECTIO_UI)-$$os-$$arch$$ext ./cmd/lectio-ui; \ done ``` `.gitignore`: ``` /lectio /lectio-ui dist/ *.test *.out coverage.* ``` `LICENSE`: copy `~/git/projects/bread-calc/LICENSE` verbatim. - [ ] **Step 4: Fetch deps and build** Run: ```bash go mod tidy go build ./cmd/lectio ./cmd/lectio-ui && ./lectio ``` Expected: `go.sum` written; build succeeds; prints `lectio: not yet implemented`. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "scaffold: module, mains, Makefile, embedded corpora" ``` --- ## Task 2: internal/psalter (versification port) **Files:** - Create: `internal/psalter/psalter.go`, `internal/psalter/psalter_test.go` - Source: port `~/git/projects/daily-reading/psalm_versify.py` verbatim. **Interfaces:** - Produces: `psalter.DrbVerse(hebrewPsalm, lectionaryVerse int) int`; `psalter.HebrewToVulgateChapter(h int) int`; `psalter.DRBTitleFold map[int]int`. - [ ] **Step 1: Write the failing test** `internal/psalter/psalter_test.go`: ```go package psalter import "testing" func TestDrbVerse(t *testing.T) { cases := []struct{ psalm, v, want int }{ {34, 2, 1}, // Ps 34: k=1, title folded {34, 3, 2}, {51, 3, 1}, // Ps 51: k=2 (two-line title) {63, 2, 1}, // Ps 63: k=1 {1, 5, 5}, // untitled: identity {34, 1, 1}, // clamp: never below 1 } for _, c := range cases { if got := DrbVerse(c.psalm, c.v); got != c.want { t.Errorf("DrbVerse(%d,%d)=%d want %d", c.psalm, c.v, got, c.want) } } } func TestHebrewToVulgateChapter(t *testing.T) { cases := []struct{ h, want int }{{8, 8}, {34, 33}, {9, 9}, {10, 9}, {116, 114}, {147, 146}, {150, 150}} for _, c := range cases { if got := HebrewToVulgateChapter(c.h); got != c.want { t.Errorf("HebrewToVulgateChapter(%d)=%d want %d", c.h, got, c.want) } } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/psalter/` — Expected: FAIL (undefined: DrbVerse). - [ ] **Step 3: Implement psalter.go** Port the data and functions from `psalm_versify.py`. `DRBTitleFold` is the exact `DRB_TITLE_FOLD` dict (63 entries) rendered as a Go map literal; `DrbVerse` and `HebrewToVulgateChapter` mirror the Python functions. `internal/psalter/psalter.go`: ```go // Package psalter bridges psalm versification between the Vulgate-family // versions and the Douay-Rheims source. See the Go port of psalm_versify.py. package psalter // DRBTitleFold maps a Hebrew psalm number to the number of title verses the DRB // source folds into verse 1 (1, or 2 for a historical superscription). Untitled // psalms are absent (k = 0). Copy every entry from psalm_versify.py DRB_TITLE_FOLD. var DRBTitleFold = map[int]int{ 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 11: 1, 12: 1, 14: 1, 18: 1, 19: 1, 20: 1, 21: 1, 22: 1, 30: 1, 31: 1, 34: 1, 36: 1, 38: 1, 39: 1, 40: 1, 41: 1, 42: 1, 44: 1, 45: 1, 46: 1, 47: 1, 48: 1, 49: 1, 51: 2, 52: 2, 53: 1, 54: 2, 55: 1, 56: 1, 57: 1, 58: 1, 59: 1, 60: 2, 61: 1, 62: 1, 63: 1, 64: 1, 65: 1, 67: 1, 68: 1, 69: 1, 70: 1, 75: 1, 76: 1, 77: 1, 80: 1, 81: 1, 83: 1, 84: 1, 85: 1, 88: 1, 89: 1, 92: 1, 102: 1, 108: 1, 140: 1, 142: 1, } // DrbVerse returns the DRB-source verse number for a lectionary (BT) psalm verse. func DrbVerse(hebrewPsalm, lectionaryVerse int) int { v := lectionaryVerse - DRBTitleFold[hebrewPsalm] if v < 1 { return 1 } return v } // HebrewToVulgateChapter maps a Masoretic psalm number to its Vulgate chapter. func HebrewToVulgateChapter(h int) int { switch { case h <= 8 || h >= 148: return h case h >= 9 && h <= 10: return 9 case h >= 11 && h <= 113: return h - 1 case h >= 114 && h <= 115: return 113 case h == 116: return 114 case h >= 117 && h <= 146: return h - 1 case h == 147: return 146 } return h } ``` - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/psalter/` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/psalter && git commit -m "psalter: port psalm versification" ``` --- ## Task 3: internal/bible — canonical books + alias resolution **Files:** - Create: `internal/bible/books.go`, `internal/bible/books_test.go` - Source: port book names/abbrevs from the four corpora and the alias table from `~/git/projects/offline_readings/corpus/book_aliases.py` + `~/git/projects/daily-reading/ewangelia.py` `POLISH_TO_EN`. **Interfaces:** - Produces: `bible.ResolveBook(query string) (canonical string, ok bool)` — resolves an English name, English prefix, or Polish alias to a canonical English book name (as used in the corpora, e.g. `"John"`, `"1 Corinthians"`, `"The Acts"`, `"Song of Solomon"`, `"Wisdom"`). - [ ] **Step 1: Write the failing test** `internal/bible/books_test.go`: ```go package bible import "testing" func TestResolveBook(t *testing.T) { cases := []struct{ in, want string }{ {"John", "John"}, {"Joh", "John"}, // English prefix {"J", "John"}, // Polish abbrev — NOT Joshua {"Łk", "Luke"}, {"1 Kor", "1 Corinthians"}, {"Jana", "John"}, {"Rodzaju", "Genesis"}, {"Mdr", "Wisdom"}, {"Pnp", "Song of Solomon"}, {"Dz", "The Acts"}, } for _, c := range cases { if got, ok := ResolveBook(c.in); !ok || got != c.want { t.Errorf("ResolveBook(%q)=%q,%v want %q", c.in, got, ok, c.want) } } if _, ok := ResolveBook("Nonsense"); ok { t.Error("ResolveBook(Nonsense) should fail") } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/bible/ -run TestResolveBook` — Expected: FAIL (undefined: ResolveBook). - [ ] **Step 3: Implement books.go** Build an alias→canonical map. Resolution order (deterministic, fixes `J`→John): (1) exact alias match (longest alias wins for multi-word), (2) exact canonical name, (3) canonical-name prefix (len ≥ 2). Port the alias sets verbatim from `book_aliases.py` `BOOKS` (73 books incl. deuterocanonicals; each entry's aliases plus the canonical name). Generate case variants (original, lower, upper) at init as `book_aliases.py` does. `internal/bible/books.go` (structure — fill `aliasSeed` from `book_aliases.py`): ```go package bible import "strings" // canonical English book names, in corpus form. // aliasSeed maps a canonical name to its accepted aliases (Polish abbrev, Polish // full name, English abbrev). COPY every entry from offline_readings book_aliases.py. var aliasSeed = map[string][]string{ "Genesis": {"Rdz", "Rodzaju", "Gen"}, "Exodus": {"Wj", "Wyjścia", "Ex", "Exod"}, // ... all 73 books, verbatim from book_aliases.py ... "John": {"J", "Jan", "Jana"}, "1 Corinthians": {"1 Kor", "1 Koryntian"}, "Song of Solomon": {"Pnp", "Pieśń nad Pieśniami"}, "The Acts": {"Dz", "Dzieje", "Dzieje Apostolskie", "Acts"}, "Wisdom": {"Mdr", "Mądrości", "Księga Mądrości"}, "Revelation": {"Ap", "Apokalipsa", "Objawienie"}, } var aliasMap map[string]string // lowercased alias -> canonical var canonical []string // canonical names for prefix matching func init() { aliasMap = map[string]string{} seen := map[string]bool{} for name, aliases := range aliasSeed { canonical = append(canonical, name) for _, a := range append([]string{name}, aliases...) { k := strings.ToLower(a) if !seen[k] { seen[k] = true aliasMap[k] = name } } } } // ResolveBook maps an English name/prefix or a Polish alias to a canonical book. func ResolveBook(query string) (string, bool) { q := strings.ToLower(strings.TrimSpace(query)) if c, ok := aliasMap[q]; ok { // exact alias/name return c, true } for _, name := range canonical { // canonical prefix, len>=2 if len(q) >= 2 && strings.HasPrefix(strings.ToLower(name), q) { return name, true } } return "", false } ``` Note: the alias map's exact match runs before prefix, so `"j"` resolves via alias to `John` and never prefix-matches `Joshua`. - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/bible/ -run TestResolveBook` — Expected: PASS. If `J`→Joshua, the alias for John is missing; verify `aliasSeed["John"]` contains `"J"`. - [ ] **Step 5: Commit** ```bash git add internal/bible/books.go internal/bible/books_test.go git commit -m "bible: canonical books + alias resolution" ``` --- ## Task 4: internal/bible — embedded corpora + Lookup **Files:** - Create: `internal/bible/bible.go`, `internal/bible/bible_test.go` **Interfaces:** - Consumes: `ResolveBook` (Task 3). - Produces: `bible.Verse{Chapter, Verse int; Text string}`; `bible.Lookup(version, book string, groups []VerseGroup) ([]Verse, []string)` where `VerseGroup` is defined in Task 5. **This task provides the corpus store + a simpler `lookupBook(version, book string) (map[[2]int]string, []int, bool)` helper**; the group-aware `Lookup` is completed in Task 5. Split accordingly: here, deliver corpus loading + a `Verses(version, book, chap int) []Verse` accessor. - [ ] **Step 1: Write the failing test** `internal/bible/bible_test.go`: ```go package bible import "testing" func TestVerses(t *testing.T) { cases := []struct { version, book string chap, verse int wantPrefix string }{ {"wuj", "Genesis", 1, 1, "Na początku stworzył Bóg"}, {"vul", "Genesis", 1, 1, "In principio creavit Deus"}, {"drb", "John", 20, 1, "AND on the first day of the week"}, {"wuj", "Wisdom", 3, 1, "A dusze sprawiedliwych"}, // deuterocanonical } for _, c := range cases { vs := Verses(c.version, c.book, c.chap) var got string for _, v := range vs { if v.Verse == c.verse { got = v.Text } } if !hasPrefix(got, c.wantPrefix) { t.Errorf("%s %s %d:%d = %q want prefix %q", c.version, c.book, c.chap, c.verse, got, c.wantPrefix) } } } func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/bible/ -run TestVerses` — Expected: FAIL (undefined: Verses). - [ ] **Step 3: Implement bible.go** Embed the four TSVs; parse each once (lazy, `sync.Once`) into `map[book]map[chap][]Verse`. ```go package bible import ( "embed" "strconv" "strings" "sync" ) //go:embed corpora/wuj.tsv corpora/vul.tsv corpora/grb.tsv corpora/drb.tsv var corporaFS embed.FS // Verse is a single verse. type Verse struct { Chapter, Verse int Text string } type corpus struct { books map[string]map[int][]Verse // book -> chapter -> verses (verse-ordered) } var ( corpora = map[string]*corpus{} corporaMu sync.Mutex ) func load(version string) *corpus { corporaMu.Lock() defer corporaMu.Unlock() if c, ok := corpora[version]; ok { return c } data, err := corporaFS.ReadFile("corpora/" + version + ".tsv") if err != nil { corpora[version] = &corpus{books: map[string]map[int][]Verse{}} return corpora[version] } c := &corpus{books: map[string]map[int][]Verse{}} for _, line := range strings.Split(string(data), "\n") { f := strings.Split(line, "\t") if len(f) != 6 { continue } chap, _ := strconv.Atoi(f[3]) vn, _ := strconv.Atoi(f[4]) book := f[0] if c.books[book] == nil { c.books[book] = map[int][]Verse{} } c.books[book][chap] = append(c.books[book][chap], Verse{chap, vn, f[5]}) } corpora[version] = c return c } // Verses returns all verses of one chapter of a book in a version (may be empty). func Verses(version, book string, chap int) []Verse { return load(version).books[book][chap] } ``` - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/bible/ -run TestVerses` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/bible/bible.go internal/bible/bible_test.go git commit -m "bible: embed corpora + chapter lookup" ``` --- ## Task 5: internal/bible — reference grammar (ParseRef, SplitRef, Lookup) **Files:** - Create: `internal/bible/ref.go`, `internal/bible/ref_test.go` **Interfaces:** - Consumes: `ResolveBook`, `Verses`. - Produces: - `type VerseGroup struct{ Chapter, From, To int }` (To==From for a single verse; a whole-verse-list "20:1,2,3" is multiple groups). - `SplitRef(ref string) []string` — split a kjv-style ref with a mixed comma/range list into single-group refs (port of Python `split_ref`). - `Lookup(version, ref string) (verses []Verse, missing []string)` — resolve a full English-style ref (`"John 20:1,11-18"`), returning matched verses in order and the sub-refs the corpus lacked. - [ ] **Step 1: Write the failing test** `internal/bible/ref_test.go`: ```go package bible import "testing" func TestSplitRef(t *testing.T) { got := SplitRef("John 20:1,11-18") want := []string{"John 20:1", "John 20:11-18"} if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { t.Fatalf("SplitRef mixed = %v want %v", got, want) } if g := SplitRef("Mat 7:1-5"); len(g) != 1 || g[0] != "Mat 7:1-5" { t.Errorf("SplitRef contiguous = %v", g) } } func TestLookup(t *testing.T) { vs, missing := Lookup("wuj", "John 20:1,11-18") if len(missing) != 0 { t.Fatalf("missing = %v", missing) } if len(vs) == 0 || vs[0].Verse != 1 { t.Fatalf("first verse = %+v", vs) } last := vs[len(vs)-1] if last.Verse != 18 { t.Errorf("last verse = %d want 18", last.Verse) } if _, m := Lookup("vul", "Wisdom 3:1"); len(m) == 0 { t.Error("vul lacks Wisdom; expected a missing entry") } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/bible/ -run 'TestSplitRef|TestLookup'` — Expected: FAIL. - [ ] **Step 3: Implement ref.go** Port `split_ref` and the reference-parsing logic. A ref is ` :` where `` is a comma list of items, each a single verse or a `from-to` range. `SplitRef` groups a mixed list (single verses + ranges) into separate refs, mirroring Python. `Lookup` resolves the book, then for each `SplitRef` part parses the chapter/range, pulls from `Verses`, and records misses. ```go package bible import ( "regexp" "strconv" "strings" ) var refRe = regexp.MustCompile(`^(.*?)\s+(\d+):(.+)$`) // SplitRef splits a ref whose verse list mixes single verses and ranges into // one ref per group (the kjv tools reject a mixed list in a single query). func SplitRef(ref string) []string { m := refRe.FindStringSubmatch(ref) if m == nil { return []string{ref} } book, chap, verses := m[1], m[2], m[3] if !strings.Contains(verses, ",") { return []string{ref} } var out []string for _, g := range strings.Split(verses, ",") { g = strings.TrimSpace(g) if g == "" { continue } if strings.Contains(g, ":") { out = append(out, book+" "+g) } else { out = append(out, book+" "+chap+":"+g) } } return out } // Lookup resolves an English-style reference against a version, returning the // matched verses (in order) and the sub-refs the corpus had no entry for. func Lookup(version, ref string) ([]Verse, []string) { var verses []Verse var missing []string for _, part := range SplitRef(ref) { m := refRe.FindStringSubmatch(part) if m == nil { missing = append(missing, part) continue } book, ok := ResolveBook(m[1]) if !ok { missing = append(missing, part) continue } chap, _ := strconv.Atoi(m[2]) from, to := verseRange(m[3]) found := false for _, v := range Verses(version, book, chap) { if v.Verse >= from && v.Verse <= to { verses = append(verses, v) found = true } } if !found { missing = append(missing, part) } } return verses, missing } func verseRange(s string) (int, int) { s = strings.TrimSpace(s) if i := strings.IndexAny(s, "-–—"); i >= 0 { from, _ := strconv.Atoi(strings.TrimSpace(s[:i])) to, _ := strconv.Atoi(strings.TrimSpace(s[i+1:])) return from, to } n, _ := strconv.Atoi(s) return n, n } ``` - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/bible/ -run 'TestSplitRef|TestLookup'` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/bible/ref.go internal/bible/ref_test.go git commit -m "bible: reference grammar + Lookup" ``` --- ## Task 6: internal/bible — Polish citation → English ref conversion **Files:** - Create: `internal/bible/convert.go`, `internal/bible/convert_test.go` - Source: port `to_english_ref` / `_psalm_ref` and `POLISH_TO_EN` from `~/git/projects/daily-reading/ewangelia.py`. **Interfaces:** - Consumes: `psalter.DrbVerse`. - Produces: `bible.ToEnglishRef(plCitation, system string) (string, error)` where `system` ∈ `"vulgate"`, `"drb"`. Converts a Polish citation (`"Mt 7, 1-5"`, `"por. J 20, 11"`, `"Ps 63 (62), 2. 3-4 (R.: por. 2ab)"`) to a kjv-style ref. - [ ] **Step 1: Write the failing test** `internal/bible/convert_test.go`: ```go package bible import "testing" func TestToEnglishRef(t *testing.T) { cases := []struct{ in, system, want string }{ {"Mt 7, 1-5", "vulgate", "Mat 7:1-5"}, {"J 20, 1. 11-18", "vulgate", "John 20:1,11-18"}, {"por. J 20, 11", "vulgate", "John 20:11"}, {"Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab)", "vulgate", "Psalms 62:2,3-4,5-6,8-9"}, {"Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab)", "drb", "Psalms 63:1,2-3,4-5,7-8"}, } for _, c := range cases { got, err := ToEnglishRef(c.in, c.system) if err != nil || got != c.want { t.Errorf("ToEnglishRef(%q,%q)=%q,%v want %q", c.in, c.system, got, err, c.want) } } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/bible/ -run TestToEnglishRef` — Expected: FAIL. - [ ] **Step 3: Implement convert.go** Port `POLISH_TO_EN` (the full dict from `ewangelia.py`, ~66 entries — note it maps to short gospel forms `Mt→Mat` etc.), `to_english_ref`, and `_psalm_ref`. Steps mirror the Python exactly: strip leading `por.`, strip trailing `(R.: ...)`, collapse whitespace, match the longest Polish book key, for Psalms route through `psalmRef` (dual-numbering pick + drb verse fold via `psalter.DrbVerse`), then the punctuation transforms (`, `→`:`, `. `→`,`, ` i `→`,`, ranges, drop verse-part letters, remove spaces). Provide the full function bodies (translated line-for-line from the Python). Key psalm helper: ```go // psalmRef maps the psalm citation body ("63 (62), 2. 3-4. ...") to the target Psalter. func psalmRef(rest, system string) string { m := regexp.MustCompile(`(\d+)(?:\s*\((\d+)\))?(.*)$`).FindStringSubmatch(rest) if m == nil { return rest } heb, _ := strconv.Atoi(m[1]) tail := m[3] if system == "vulgate" { ch := m[1] if m[2] != "" { ch = m[2] } return ch + tail } if system == "drb" { tail = regexp.MustCompile(`\d+`).ReplaceAllStringFunc(tail, func(s string) string { n, _ := strconv.Atoi(s) return strconv.Itoa(psalter.DrbVerse(heb, n)) }) } return strconv.Itoa(heb) + tail } ``` `ToEnglishRef` returns an `error` when no Polish book matches (mirrors the Python `ValueError`). - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/bible/` — Expected: PASS (all bible tests). - [ ] **Step 5: Commit** ```bash git add internal/bible/convert.go internal/bible/convert_test.go git commit -m "bible: Polish citation -> English ref (with psalm versification)" ``` --- ## Task 7: internal/liturgy — Section type + Parse **Files:** - Create: `internal/liturgy/section.go`, `internal/liturgy/parse.go`, `internal/liturgy/parse_test.go` - Create fixtures: `internal/liturgy/testdata/2026-07-22.html` (split feast), `2026-06-22.html` (normal day). Copy from `~/.cache/daily-reading/`. **Interfaces:** - Produces: `liturgy.Section{Heading, Subtitle, Citation string; Paragraphs [][]string}`; `liturgy.Parse(html string) ([]Section, error)`; `liturgy.ExtractCitation(heading string) (string, error)`. - [ ] **Step 1: Copy fixtures + write the failing test** ```bash mkdir -p internal/liturgy/testdata cp ~/.cache/daily-reading/2026-07-22.html internal/liturgy/testdata/ cp ~/.cache/daily-reading/2026-06-22.html internal/liturgy/testdata/ ``` `internal/liturgy/parse_test.go`: ```go package liturgy import ( "os" "strings" "testing" ) func TestParse(t *testing.T) { html, _ := os.ReadFile("testdata/2026-07-22.html") secs, err := Parse(string(html)) if err != nil { t.Fatal(err) } var gospel *Section for i := range secs { if strings.HasPrefix(secs[i].Heading, "Ewangelia") { gospel = &secs[i] } } if gospel == nil { t.Fatal("no gospel section") } if gospel.Citation != "J 20, 1. 11-18" { t.Errorf("gospel citation = %q", gospel.Citation) } if len(gospel.Paragraphs) == 0 { t.Error("gospel has no paragraphs") } } func TestParseLayoutChange(t *testing.T) { if _, err := Parse("redesigned"); err == nil { t.Error("expected error on missing reading tab") } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/liturgy/ -run TestParse` — Expected: FAIL (undefined: Parse). - [ ] **Step 3: Implement section.go + parse.go** Port `parse_sections`, `html_to_lines`, `extract_reference` from `ewangelia.py`. Prefer the `tabnowy0all` tab; fall back to `tabstary0all`; error if neither, or if found-but-empty. Use `regexp` and `html.UnescapeString`. `ExtractCitation` pulls the parenthetical from a heading. Provide full Go translations. Key regexes: pane `
`, headings `

(.*?)

` (with `(?s)`), subtitle `

(.*?)

`, paragraphs `

(.*?)

`, `` → newline, strip `<[^>]+>`. - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/liturgy/ -run TestParse` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/liturgy/section.go internal/liturgy/parse.go internal/liturgy/parse_test.go internal/liturgy/testdata git commit -m "liturgy: Section type + HTML parse (fixtures)" ``` --- ## Task 8: internal/liturgy — Fetch + cache + Load **Files:** - Create: `internal/liturgy/fetch.go`, `internal/liturgy/fetch_test.go` **Interfaces:** - Consumes: `Parse`. - Produces: - `type Options struct{ Date string; Refresh, Offline bool }` - `liturgy.Load(opts Options) ([]Section, error)` — JSON cache → HTML cache(+parse, write JSON) → fetch(+cache both). (Offline path added in Task 9.) - `liturgy.cacheDir() string`, using `XDG_CACHE_HOME`. - [ ] **Step 1: Write the failing test (cache round-trip via a local server)** `internal/liturgy/fetch_test.go`: ```go package liturgy import ( "net/http" "net/http/httptest" "os" "testing" ) func TestLoadCaches(t *testing.T) { html, _ := os.ReadFile("testdata/2026-06-22.html") hits := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.Write(html) })) defer srv.Close() t.Setenv("XDG_CACHE_HOME", t.TempDir()) baseURL = srv.URL + "/liturgia/%s/Ewangelia" // test hook secs1, err := Load(Options{Date: "2026-06-22"}) if err != nil || len(secs1) == 0 { t.Fatalf("load1: %v", err) } secs2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache if hits != 1 { t.Errorf("server hit %d times, want 1 (cache miss on repeat)", hits) } if len(secs2) != len(secs1) { t.Error("cache returned different section count") } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/liturgy/ -run TestLoadCaches` — Expected: FAIL. - [ ] **Step 3: Implement fetch.go** `baseURL` is a package var (default the real URL, overridable in tests). `Load`: compute cache paths; if not `Refresh`, try `{date}.json` (unmarshal `[]Section`); else try `{date}.html` (parse, write JSON); else GET `baseURL` with the User-Agent, and — only if the page is fully published (`id="\w*0all"` present) — write `{date}.html` and `{date}.json`. Publish detection and the "Przykro nam"/unpublished handling mirror `ewangelia.py`. - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/liturgy/ -run TestLoadCaches` — Expected: PASS (`hits == 1`). - [ ] **Step 5: Commit** ```bash git add internal/liturgy/fetch.go internal/liturgy/fetch_test.go git commit -m "liturgy: fetch + HTML/JSON cache" ``` --- ## Task 9: internal/liturgy — sigla harvest + offline Load **Files:** - Create: `internal/liturgy/store.go`, `internal/liturgy/store_test.go` - Modify: `internal/liturgy/fetch.go` (Load consults offline path when `opts.Offline`) **Interfaces:** - Produces: - `liturgy.Harvest(fromDate string, maxDays int) (added int, furthest string, err error)` — walks dates, extracts citations, writes/merges the sigla TSV. Stops at the unpublished horizon. - `liturgy.LoadOffline(date string) ([]Section, error)` — build sections from the sigla TSV (Citation set, Paragraphs empty). - `liturgy.siglaPath() string` (`XDG_DATA_HOME`). - Modify `Load`: when `opts.Offline`, return `LoadOffline(opts.Date)`. - [ ] **Step 1: Write the failing test** `internal/liturgy/store_test.go`: ```go package liturgy import ( "net/http" "net/http/httptest" "os" "strings" "testing" ) func TestHarvestAndOffline(t *testing.T) { html, _ := os.ReadFile("testdata/2026-07-22.html") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "2026-07-22") { w.Write(html) } else { w.Write([]byte("Przykro nam")) // horizon } })) defer srv.Close() t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("XDG_DATA_HOME", t.TempDir()) baseURL = srv.URL + "/liturgia/%s/Ewangelia" added, _, err := Harvest("2026-07-22", 3) if err != nil || added < 1 { t.Fatalf("harvest: added=%d err=%v", added, err) } secs, err := LoadOffline("2026-07-22") if err != nil { t.Fatal(err) } var haveGospel bool for _, s := range secs { if s.Citation == "J 20, 1. 11-18" { haveGospel = true } } if !haveGospel { t.Error("offline gospel citation missing") } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/liturgy/ -run TestHarvestAndOffline` — Expected: FAIL. - [ ] **Step 3: Implement store.go + modify Load** `Harvest`: from `fromDate`, for up to `maxDays` (0 = until horizon), fetch+parse each date; on "no reading published"/parse error, stop (horizon). For each section, `ExtractCitation` → append rows `date\tlabel\tcitation`. Merge with existing TSV (replace a date's rows on re-harvest). `LoadOffline`: read the TSV, gather rows for the date, build `[]Section{Heading:label, Citation:citation}`. Date iteration uses `time.Parse("2006-01-02", ...)` + `AddDate(0,0,1)`. In `Load`: add `if opts.Offline { return LoadOffline(opts.Date) }` at the top; and wrap the network branch so that on a fetch error it falls back to `LoadOffline(opts.Date)` when the date is harvested, returning the original fetch error only if the offline load also fails (auto-fallback per the spec). - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/liturgy/` — Expected: PASS (all liturgy tests). - [ ] **Step 5: Commit** ```bash git add internal/liturgy/store.go internal/liturgy/store_test.go internal/liturgy/fetch.go git commit -m "liturgy: sigla harvest + offline load" ``` --- ## Task 10: internal/config **Files:** - Create: `internal/config/config.go`, `internal/config/config.toml` (embedded seed), `internal/config/config_test.go` **Interfaces:** - Produces: `config.Config{Versions []string; DefaultVersion string; Width int; All, Offline bool}`; `config.Load() (Config, error)` (env → `~/.config/lectio/config.toml` seed → defaults); `config.Default() Config`. - [ ] **Step 1: Write the failing test** `internal/config/config_test.go`: ```go package config import ( "os" "path/filepath" "testing" ) func TestLoadSeeds(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) cfg, err := Load() if err != nil { t.Fatal(err) } if cfg.DefaultVersion != "pl" || cfg.Offline { t.Errorf("defaults wrong: %+v", cfg) } if _, err := os.Stat(filepath.Join(dir, "lectio", "config.toml")); err != nil { t.Error("config not seeded") } } func TestLoadOverride(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) os.MkdirAll(filepath.Join(dir, "lectio"), 0o755) os.WriteFile(filepath.Join(dir, "lectio", "config.toml"), []byte("offline = true\ndefault_version = \"wuj\"\n"), 0o644) cfg, _ := Load() if !cfg.Offline || cfg.DefaultVersion != "wuj" { t.Errorf("override not applied: %+v", cfg) } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/config/` — Expected: FAIL. - [ ] **Step 3: Implement config.go + seed** `internal/config/config.toml` = the seed from the spec (with `schema_version`, `versions`, `default_version`, `width`, `all`, `offline = false`), `//go:embed`ed. `Default()` returns the same values. `Load`: `LECTIO_CONFIG` env → `os.UserConfigDir()/lectio/config.toml` (seed the embedded default if absent) → parse with `toml.Unmarshal`. Validate each version in `Versions`/`DefaultVersion` against the five codes; error on unknown. Struct tags match the TOML keys. - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/config/` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/config && git commit -m "config: TOML load + seed" ``` --- ## Task 11: internal/render **Files:** - Create: `internal/render/render.go`, `internal/render/render_test.go` **Interfaces:** - Consumes: `liturgy.Section`, `bible.ToEnglishRef`, `bible.Lookup`, version labels/systems. - Produces: - `render.GatherVersion(version string, sec liturgy.Section) (label string, blocks []string)` — `pl` returns deduped paragraphs (drop "Słowa Ewangelii" incipit + repeated refrains); a bible version returns verse lines `"C:V text"` plus a "brak" note for missing groups. - `render.Compare(secs []liturgy.Section, versions []string, width int) string` — the side-by-side column layout (port `render_compare`). - `render.OfflineVersions(versions []string) []string` — drop `pl`, ensure `wuj`. - [ ] **Step 1: Write the failing test** `internal/render/render_test.go`: ```go package render import ( "strings" "testing" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) func TestGatherPLDedup(t *testing.T) { sec := liturgy.Section{ Heading: "Psalm (Ps 1)", Paragraphs: [][]string{{"stanza one"}, {"refrain"}, {"stanza two"}, {"refrain"}}, } _, blocks := GatherVersion("pl", sec) n := 0 for _, b := range blocks { if b == "refrain" { n++ } } if n != 1 { t.Errorf("refrain appears %d times, want 1 (deduped)", n) } } func TestGatherBible(t *testing.T) { sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} label, blocks := GatherVersion("wuj", sec) if !strings.Contains(label, "Wujek") { t.Errorf("label = %q", label) } if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "20:1") { t.Errorf("first block = %q", blocks) } } func TestOfflineVersions(t *testing.T) { got := OfflineVersions([]string{"pl", "wuj", "vul"}) for _, v := range got { if v == "pl" { t.Error("pl not dropped offline") } } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/render/` — Expected: FAIL. - [ ] **Step 3: Implement render.go** Port `gather_version` (pl branch with incipit-drop + dedup; bible branch via `bible.ToEnglishRef(citation, system(version))` then `bible.Lookup`), the version labels/systems maps, `render_compare` (column widths, gap, label+dash header, row assembly), and `OfflineVersions`. `GatherVersion` derives the citation from `sec.Citation` (fallback `ExtractCitation(sec.Heading)`). - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/render/` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/render && git commit -m "render: gather versions + compare + dedup" ``` --- ## Task 12: internal/cli — subcommand dispatch **Files:** - Replace: `internal/cli/cli.go` (the Task 1 stub); Create: `internal/cli/cli_test.go` **Interfaces:** - Consumes: `config.Load`, `liturgy.Load/Harvest`, `render.*`. - Produces: full `Run(args, stdin, stdout, stderr) int` handling `today`, `date D`, `compare LIST`, `show VERSION`, `update`, `--version`, `help`; global `--offline`; per-command `--all --raw --width --refresh --date`. - [ ] **Step 1: Write the failing test** `internal/cli/cli_test.go`: ```go package cli import ( "bytes" "strings" "testing" ) func TestHelp(t *testing.T) { var out, errb bytes.Buffer code := Run([]string{"help"}, nil, &out, &errb) if code != 0 || !strings.Contains(out.String(), "lectio") { t.Errorf("help code=%d out=%q", code, out.String()) } } func TestUnknownCommand(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"bogus"}, nil, &out, &errb); code != 2 { t.Errorf("unknown cmd code=%d want 2", code) } } func TestVersion(t *testing.T) { var out, errb bytes.Buffer if code := Run([]string{"--version"}, nil, &out, &errb); code != 0 { t.Errorf("version code=%d", code) } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/cli/` — Expected: FAIL. - [ ] **Step 3: Implement cli.go** Hand-rolled dispatch (like bread-calc): first non-flag arg is the subcommand (default `today`). Each subcommand builds a `flag.FlagSet`, resolves `config.Load()` (flags override), calls `liturgy.Load`/`Harvest`, renders via `render`, writes to `stdout`. `--offline`/config sets `Options.Offline` and swaps versions via `render.OfflineVersions`. `help`/`-h` prints usage; unknown command → usage error (2); runtime errors → 1. Include a `helpText` const listing every subcommand (mirror the spec's CLI section). - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/cli/` — Expected: PASS. - [ ] **Step 5: Build + manual smoke + commit** ```bash go build ./cmd/lectio && ./lectio today && ./lectio compare pl,wuj,vul,drb git add internal/cli && git commit -m "cli: subcommand dispatch" ``` Expected: today's gospel prints; compare shows four columns (network required). --- ## Task 13: internal/tui — styles + model **Files:** - Create: `internal/tui/styles.go`, `internal/tui/tui.go`, `internal/tui/tui_test.go` - Replace: `cmd/lectio-ui/main.go` **Interfaces:** - Consumes: `config.Config`, `liturgy.Load`, `render.GatherVersion`, `render.OfflineVersions`. - Produces: `tui.New(cfg config.Config) tui.Model` implementing `tea.Model`. - [ ] **Step 1: Write the failing test (pure model logic)** `internal/tui/tui_test.go`: ```go package tui import ( "testing" "github.com/lukaszkasprzak/lectio/internal/config" ) func TestVersionCycle(t *testing.T) { m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl"}) if m.version() != "pl" { t.Fatalf("start = %q", m.version()) } m = m.cycleVersion(+1) if m.version() != "wuj" { t.Errorf("after tab = %q", m.version()) } m = m.cycleVersion(-1) if m.version() != "pl" { t.Errorf("after shift-tab = %q", m.version()) } } func TestOfflineDropsPL(t *testing.T) { m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl", Offline: true}) for _, v := range m.versions { if v == "pl" { t.Error("offline model kept pl") } } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/tui/` — Expected: FAIL. - [ ] **Step 3: Implement styles.go + tui.go** `styles.go`: named `lipgloss.Style` per color role (heading bold accent, citation dim, verse number muted, verse text default, refrain italic, header, footer) using `lipgloss.AdaptiveColor`; honor `NO_COLOR`. `tui.go`: `Model{cfg, versions, verIdx, date, sections, scroll, width, loading, err}`; `New` applies `OfflineVersions` when `cfg.Offline` and sets `verIdx` to `default_version`; `version()`, `cycleVersion(d int)`, helpers are pure and unit-tested. `Init` issues the first load `tea.Cmd`; `Update` handles `tea.KeyMsg` (tab/arrows/j/k/g/G/r/q), `readingsMsg`, `errMsg`, `tea.WindowSizeMsg`; `View` renders header + styled reading (via `render.GatherVersion` for the active version, styled per role) + footer keybar. Fetching runs in a `tea.Cmd` returning `readingsMsg`/`errMsg`. - [ ] **Step 4: Replace cmd/lectio-ui/main.go** ```go package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/tui" ) func main() { cfg, err := config.Load() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if _, err := tea.NewProgram(tui.New(cfg), tea.WithAltScreen()).Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` - [ ] **Step 5: Run tests + build + commit** ```bash go test ./internal/tui/ && go build ./cmd/lectio-ui git add internal/tui cmd/lectio-ui && git commit -m "tui: colored reader + version switch" ``` Expected: tests pass; builds. (Interactive run verified manually: `./lectio-ui`.) --- ## Task 14: README, vet, cross-build, finalize **Files:** - Create: `README.md` **Interfaces:** none. - [ ] **Step 1: Write README** Cover: what it is, install (`make install`), the subcommands, config file + keys, the TUI keys and colors, the `update`/offline workflow, and that it's self-contained (no external tools). Note the Python `daily-reading` is the predecessor. - [ ] **Step 2: vet + fmt + full test** Run: `gofmt -w . && go vet ./... && go test ./...` Expected: no vet diagnostics; all tests pass. - [ ] **Step 3: Cross-build sanity** Run: `make cross && ls dist/` Expected: ten binaries. - [ ] **Step 4: Install + smoke both binaries** Run: `make install && lectio today && echo '---' && lectio update --days 5` Expected: gospel prints; update reports harvested days. (`lectio-ui` verified interactively.) - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "docs: README; finalize build" ``` --- ## Addendum A: Traditional lectionary (missalemeum) Adds `lectionary = new|traditional`. Traditional propers come from the missalemeum API. Integrates via the shared `[]Section` type. Do these after the base plan (or interleave: A1–A2 before Task 11). ### Amendment to Task 7 (Section type) Add a `PartID string` field to `Section`: ```go type Section struct { Heading, Subtitle, Citation, PartID string Paragraphs [][]string } ``` In modern `Parse`, set `PartID` from the heading: map `1. czytanie`→`pierwsze_czytanie` (or `drugie_czytanie` for a second `1. czytanie` occurrence), `Psalm`→`psalm`, `Aklamacja`→`aklamacja`, `Ewangelia`→`ewangelia`. ### Amendment to Task 10 (config) Add fields and `PartShown`; extend the embedded seed with the commented `lectionary`, `traditional_lang`, and `[parts.*]` blocks from the spec. ```go type Config struct { SchemaVersion int `toml:"schema_version"` Lectionary string `toml:"lectionary"` TraditionalLang string `toml:"traditional_lang"` Versions []string `toml:"versions"` DefaultVersion string `toml:"default_version"` Width int `toml:"width"` All bool `toml:"all"` Offline bool `toml:"offline"` Parts map[string]map[string]bool `toml:"parts"` } // PartShown reports whether a part renders: true unless explicitly set false. func (c Config) PartShown(lectionary, partID string) bool { if m, ok := c.Parts[lectionary]; ok { if v, ok := m[partID]; ok { return v } } return true } ``` Defaults: `Lectionary="new"`, `TraditionalLang="pl"`. Validate `Lectionary` ∈ {new,traditional}, `TraditionalLang` ∈ {pl,en}. Add a `TestPartShown` (empty → all true; `{new:{psalm:false}}` → psalm false, ewangelia true). ### Amendment to Tasks 8–9 (Load router) `Load(cfg config.Config, opts Options) ([]Section, error)`: if `cfg.Lectionary=="traditional"`, delegate to `tradlit.Load(opts.Date, cfg.TraditionalLang)` (offline: `tradlit.LoadOffline`); else the modern path. Then filter: `sections = keep(s for s where cfg.PartShown(cfg.Lectionary, s.PartID))`, unless `opts.GospelOnly`, which keeps only `ewangelia`/`evangelium`. Traditional cache lives under `traditional/{date}.json`; harvest writes `sigla-traditional.tsv`. ### Task A1: internal/tradlit — fetch + parse missalemeum propers **Files:** Create `internal/tradlit/tradlit.go`, `internal/tradlit/parse_test.go`, `internal/tradlit/testdata/2026-07-22.json` (save `curl -s https://www.missalemeum.com/en/api/v5/proper/2026-07-22`). **Interfaces:** - Consumes: `liturgy.Section`. - Produces: `tradlit.Parse(jsonBody []byte) ([]liturgy.Section, error)`; `tradlit.Load(date, lang string) ([]liturgy.Section, error)`. - [ ] **Step 1: Save fixture + write the failing test** ```bash mkdir -p internal/tradlit/testdata curl -s -A 'Mozilla/5.0' https://www.missalemeum.com/en/api/v5/proper/2026-07-22 \ > internal/tradlit/testdata/2026-07-22.json ``` `internal/tradlit/parse_test.go`: ```go package tradlit import ( "os" "testing" ) func TestParse(t *testing.T) { body, _ := os.ReadFile("testdata/2026-07-22.json") secs, err := Parse(body) if err != nil { t.Fatal(err) } var gospel, epistle bool for _, s := range secs { if s.PartID == "evangelium" { gospel = true if s.Citation != "Luke 7:36-50" { t.Errorf("gospel citation = %q", s.Citation) } if len(s.Paragraphs) == 0 { t.Error("gospel has no vernacular text") } } if s.PartID == "lectio" { epistle = true } } if !gospel || !epistle { t.Errorf("missing parts: gospel=%v epistle=%v", gospel, epistle) } } ``` - [ ] **Step 2: Run test, verify it fails** Run: `go test ./internal/tradlit/` — Expected: FAIL. - [ ] **Step 3: Implement tradlit.go** Unmarshal `[]struct{ Info struct{Title string} `json:"info"`; Sections []struct{ ID, Label string; Body [][]string } }`. For each section: `PartID = strings.ToLower(ID)`, `Heading = Label`, join `Body[0]` into `Paragraphs`, and extract the citation from the first `*...*` marker in the body text (`regexp.MustCompile(`\*([^*\n]+)\*`)`). Skip empty/administrative sections. `Load` fetches `https://www.missalemeum.com/{lang}/api/v5/proper/{date}` with the User-Agent and calls `Parse`; a 404 means "no propers for this date". The citation is already kjv-style (e.g. `Luke 7:36-50`, `Ps 44:2`), so `bible.Lookup` consumes it directly; psalm citations are Vulgate-numbered (1962 Missal) — feed the version lookup as-is for `vul/grb/wuj`; `drb` psalms may sit a verse off (same known limitation as the modern mode, no extra handling). - [ ] **Step 4: Run tests, verify pass** Run: `go test ./internal/tradlit/` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/tradlit && git commit -m "tradlit: missalemeum 1962 propers -> sections" ``` ### Amendment to Task 11 (render) and Task 12 (cli) `render.GatherVersion`: for a traditional section, the vernacular source column is `sec.Paragraphs` (used for the `pl` role / when a part has no citation); the four bible versions use `sec.Citation` when present. A prayer part (no citation) renders vernacular only, with a one-line note that Latin is unavailable. `cli`: add global `--lectionary`/`--lang` flags overriding config; everything else is unchanged because both sources yield `[]Section`. ## Addendum B: lectio-web (HTMX web UI with selectable themes) A third binary `lectio-web` — an hledger-web-style local server. Build after the base plan + Addendum A. Consumes `readings.Load` + `render.GatherVersion`; imports the domain packages, never the reverse. ### Amendment to Task 10 (config) — web fields Add to `Config`: `WebTheme string \`toml:"web_theme"\``, `WebPort int \`toml:"web_port"\``. Defaults: `WebTheme "transfiguration"`, `WebPort 0`. Do NOT hard-validate `WebTheme` against a fixed set — themes are dynamic (built-ins + user files in `~/.config/lectio/themes/`), so an unknown name is resolved (and, if missing, warned + defaulted) at serve time in `internal/web`, not rejected at config load. Add to the embedded seed (after `offline`): `web_theme = "transfiguration"` and `web_port = 0` with the spec's comments. (Small follow-up commit to internal/config.) ### Task B1: internal/web — HTML render + embedded themes **Files:** Create `internal/web/render.go`, `internal/web/render_test.go`, `internal/web/templates/*.html`, `internal/web/static/base.css`, `internal/web/static/themes/{transfiguration,desert_fathers,benedictines,franciscans,memento_mori,camaldolese,advent,nativity,lent,easter,pentecost,ordinary}.css`, `internal/web/static/htmx.min.js` (download the pinned release), and a `docs/THEMES.md` documenting the role classes for people writing their own. **Theme aesthetic + palettes.** COLOUR ONLY, no gimmicks: a theme file sets only colour values for the role variables — `--bg`, `--fg` (body), `.heading` (section heading, accent), `.citation` (muted), `.vnum` (verse number, muted/secondary), `.refrain` (secondary/link), `a`/link, and borders. NO background images, per-theme fonts, ornaments, gradients, shadows, animations or effects. `base.css` holds ALL layout/typography/spacing (spare serif/system reading font, generous line-height, no rounded chrome) and themes never touch it. Palettes (hex) — religious orders: - **transfiguration** — read the exact tokens from `/home/lukasz/git/transfiguration-themes/palettes/transfiguration.json`: bg `#262e28`, fg `#e6dec6`, accent `#d3b380` (gold), muted `#99a18c`, refrain/link `#97ad6e` (olive), border `#4a5347`, vnum `#d3b380`. (dark) - **desert_fathers** (light) — bg `#ede4d3` (sand), fg `#4a3f2f` (umber), muted `#8a7a5f`, accent `#b8894a` (ochre), link `#7d6b47`, refrain `#9a7b3f`, border `#cbbfa6`. - **benedictines** (dark) — bg `#17130f` (black habit), fg `#e8dcc0` (parchment), muted `#8a7d5f`, accent `#c9a227` (gold), link `#b08d3a`, refrain `#a68b4a`, border `#2e261c`. - **franciscans** (dark warm) — bg `#3a2f26` (undyed brown), fg `#e0d5c3`, muted `#9c8b76`, accent `#a8703a` (tau/terracotta), link `#7a8a5a` (olive), refrain `#8a9b6a`, border `#4d3f33`. - **memento_mori** (dark greyscale) — bg `#1a1a1a` (ash), fg `#d8d4cc` (bone), muted `#6b6862`, accent `#c4bdb0` (bone), link `#9a968e`, refrain `#8a8680`, border `#333330`; use `#7a4a42` (dried blood) ONLY for an error/alert accent. - **camaldolese** (light) — bg `#f2f1ee`, fg `#3a3f44` (slate), muted `#8b9196`, accent `#5f7a86` (slate blue), link `#6a8a94`, refrain `#7a9a88` (sage), border `#d8dad9`. Liturgical seasons (proper colours): - **advent** (dark violet) — bg `#241b33`, fg `#e6e0ef`, muted `#7a7088`, accent `#8a6db0` (violet), link `#9a86c0`, refrain `#b0708a` (rose), border `#372c4a`. - **nativity** (light white/gold) — bg `#faf6ec`, fg `#3a3226`, muted `#9a9080`, accent `#c9a227` (gold), link `#b8894a`, refrain `#4a7a5a` (evergreen), border `#e6dcc6`. - **lent** (dark ashen violet) — bg `#2a2530`, fg `#cfc8d2`, muted `#6e6675`, accent `#7a6a86` (muted violet), link `#86788f`, refrain `#8a6a72` (dried rose), border `#3a3440`. - **easter** (light white/gold, radiant) — bg `#fdfbf4`, fg `#33302a`, muted `#a09a8a`, accent `#d4af37` (bright gold), link `#4a8a9a`, refrain `#c9a227`, border `#ece7d8`. - **pentecost** (dark ember/red) — bg `#241210`, fg `#f0e0d8`, muted `#9a7a70`, accent `#d9705a` (flame), link `#e0975a` (orange), refrain `#c9a227` (gold), border `#3a201c`. - **ordinary** (light green) — bg `#eef2e6`, fg `#33382e`, muted `#8a9080`, accent `#5a7a3f` (green), link `#6a8a5a`, refrain `#7a9a4f`, border `#d8e0cc`. Each theme file MUST define every role variable (a missing one shows unstyled). `docs/THEMES.md` lists the classes/vars so users can copy one theme file into `~/.config/lectio/themes/mytheme.css` and adjust. **Interfaces:** - Consumes `liturgy.Section`, `render.GatherVersion`, `render.OfflineVersions`. - Produces: `web.RenderReadings(secs []liturgy.Section, versions []string, lectionary string) template.HTML` (the reading pane: per section, a heading + one column per version built from `render.GatherVersion(v, sec, lectionary)`; verse-number / heading / citation / refrain wrapped in CSS-class spans so themes restyle them); `web.Themes() []string` (built-in theme stems PLUS any `*.css` in `${XDG_CONFIG_HOME:-~/.config}/lectio/themes/`, deduped, sorted, user overriding a built-in of the same name); and `web.themeCSS(name string) ([]byte, error)` (reads the user file `~/.config/lectio/themes/.css` if present, else the embedded theme, else error). Embed templates + `base.css` + built-in themes + htmx via `go:embed`. - [ ] **Step 1: Write the failing test** ```go func TestRenderReadings(t *testing.T) { secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} html := string(RenderReadings(secs, []string{"wuj"}, "new")) if !strings.Contains(html, "Ewangelia") || !strings.Contains(html, "class=") { t.Errorf("reading pane missing heading/classes: %q", html[:min(200, len(html))]) } } func TestBuiltinThemes(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no user themes for _, name := range []string{"transfiguration", "desert_fathers", "benedictines", "franciscans", "memento_mori", "camaldolese"} { if b, err := themeCSS(name); err != nil || len(b) == 0 { t.Errorf("built-in theme %s not embedded", name) } } } func TestUserTheme(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) td := filepath.Join(dir, "lectio", "themes") os.MkdirAll(td, 0o755) os.WriteFile(filepath.Join(td, "mine.css"), []byte(".heading{color:#f00}"), 0o644) found := false for _, n := range Themes() { if n == "mine" { found = true } } if !found { t.Error("user theme 'mine' not listed by Themes()") } if b, err := themeCSS("mine"); err != nil || len(b) == 0 { t.Errorf("user theme not read: %v", err) } } ``` - [ ] **Step 2:** `go test ./internal/web/` → FAIL. - [ ] **Step 3:** Implement render.go: `//go:embed templates static` FS; parse templates once; `RenderReadings` builds the pane by calling `render.GatherVersion` per (section, version) and feeding a `templates/readings.html` fragment; wrap heading/citation/verse-number/refrain in ``. `Themes()` returns the union of the embedded theme stems and the `*.css` stems in `${XDG_CONFIG_HOME:-~/.config}/lectio/themes/` (dedupe, sort). `themeCSS(name)`: if `~/.config/lectio/themes/.css` exists read it, else the embedded `static/themes/.css`, else error. Write `base.css` (layout/typography, no colours) + the six religious-order theme CSS files per the palettes above (each sets ONLY the role classes). Download the pinned `htmx.min.js` into `static/`. Write `docs/THEMES.md` documenting the role classes. - [ ] **Step 4:** `go test ./internal/web/` → PASS (built-in + user-theme tests). - [ ] **Step 5:** Commit `web: HTML render + embedded themes`. ### Task B2: internal/web — server + handlers + cmd/lectio-web **Files:** Create `internal/web/server.go`, `internal/web/server_test.go`, `cmd/lectio-web/main.go`. **Interfaces:** - Consumes `config.Config`, `readings.Load`, `bible.Lookup`, B1's render. - Produces: `web.NewServer(cfg config.Config) http.Handler`; `web.Run(cfg) error` (pick port = cfg.WebPort or a free one, start, open the browser). - [ ] **Step 1: Write the failing test** (httptest against the handler, no real browser) ```go func TestIndexAndPartial(t *testing.T) { liturgy.SetBaseURL(fixtureServerURL(t) + "/liturgia/%s/Ewangelia") // reuse the T8 hook srv := NewServer(config.Default()) // GET /?date=2026-07-22&v=wuj -> 200, contains a reading + the theme + htmx script rec := httptest.NewRecorder() srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil)) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "htmx") { t.Fatalf("index: %d", rec.Code) } // GET /readings (HTMX partial) -> 200, fragment only (no ) rec2 := httptest.NewRecorder() srv.ServeHTTP(rec2, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj", nil)) if rec2.Code != 200 || strings.Contains(rec2.Body.String(), " contains the verse rec3 := httptest.NewRecorder() srv.ServeHTTP(rec3, httptest.NewRequest("GET", "/lookup?ref=J+20:1&v=wuj", nil)) if !strings.Contains(rec3.Body.String(), "20:1") { t.Fatalf("lookup missing verse") } // GET /theme.css?name=sepia -> text/css rec4 := httptest.NewRecorder() srv.ServeHTTP(rec4, httptest.NewRequest("GET", "/theme.css?name=sepia", nil)) if rec4.Code != 200 || !strings.Contains(rec4.Header().Get("Content-Type"), "css") { t.Fatalf("theme.css") } } ``` - [ ] **Step 2:** `go test ./internal/web/` → FAIL. - [ ] **Step 3:** Implement server.go: routes — `/` full page (date/lectionary/version/all/theme controls with `hx-get="/readings"` targeting the pane; the passage-lookup form `hx-get="/lookup"`; theme `` + a theme `` in the top bar (horizontal/vertical/ interlinear) carrying `hx-get=/readings`; `/readings` reads `display` (default `cfg.WebDisplay`) and passes it to `RenderReadings`; the `/` page seeds the select from `cfg.WebDisplay`. - **README** (folds into Task 14): document the three modes + `web_display`. ## Post-build theme amendment (2026-07-23) The built-in theme set was revised after the initial build; `docs/THEMES.md` and the `internal/web/static/themes/*.css` files are the authoritative list. Changes vs the Task B1 palettes above: `camaldolese` removed (insufficiently distinct); `dominicans` (black & white), `marian` (light blue/white/gold), `epiphany` (dark indigo/gold) added; `pentecost` retinted to red/white/gold; `franciscans` ground darkened. (`deus_vult` was added then removed.) Two type mechanisms were introduced, both routed through `base.css` (`--font-reading: var(--theme-font, )`): a theme may set `--theme-font` to change its reading face, and a global monospace toggle — config `web_mono` plus a client-side "mono" top-bar checkbox that sets `body.mono` — forces monospace for any theme. Final count: 14 themes (6 religious orders, 8 seasons and feasts). ## Self-Review Notes - Spec coverage: two binaries (T1,12,13,15-via-14), embedded corpora (T1,4), lookup (T4,5), aliases incl. `J`→John (T3), citation conversion + psalm systems (T2,6), fetch/parse (T7,8), cache HTML+JSON (T8), sigla harvest + offline (T9), config incl. `offline` (T10), render + dedup + pl→wuj (T11), subcommands (T12), colored reader TUI (T13), Makefile/cross/README (T1,14). All spec sections map to a task. - Bulk data (73-book aliases, `POLISH_TO_EN`, `DRB_TITLE_FOLD`) is ported verbatim from named source files — concrete, not placeholder. - Type consistency: `Verse`, `Section`, `Options`, `Config`, `GatherVersion`, `Lookup`, `ToEnglishRef`, `DrbVerse` names are used identically across tasks.