diff options
Diffstat (limited to 'docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md')
| -rw-r--r-- | docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md | 1378 |
1 files changed, 1378 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md b/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md new file mode 100644 index 0000000..84e5c32 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md @@ -0,0 +1,1378 @@ +# 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 `<book> <chap>:<verses>` where `<verses>` 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("<html><body>redesigned</body></html>"); 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 `<div class="tab-pane[^"]*"\s+id="%s">`, headings `<h2>(.*?)</h2>` (with `(?s)`), subtitle `<h4>(.*?)</h4>`, paragraphs `<p>(.*?)</p>`, `<br\s*/?>` → 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("<html>Przykro nam</html>")) // 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" +``` + +--- + +## 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. |
