# lectio — design spec Date: 2026-07-23 Status: approved (brainstorm), pre-plan ## Goal Reimplement the Python `daily-reading` tool (`ewangelia.py`) as a self-contained Go project with two binaries: - **`lectio`** — terminal CLI (subcommand-based). - **`lectio-ui`** — TUI reader (Bubble Tea), modelled on the user's `bread-calc`. It fetches the daily Catholic liturgy readings from niezbednik.niedziela.pl and shows them in Polish plus four other versions (Wujek Polish, Vulgate Latin, Greek, Douay-Rheims English), with correct psalm versification across them. This is a **fork**: the Python `daily-reading` project is left untouched and keeps working. `lectio` is a fresh project. ## Non-goals - **Computing** the day's citations from a liturgical-calendar engine (for dates the site never published, or with no network and no prior harvest) remains the separate `offline_readings` effort. `lectio` gets citations from the site, or from a prior `lectio update` harvest — it does not compute them. When an offline engine exists it slots in behind `internal/liturgy` unchanged. - No new translations beyond the five already supported. Note: harvesting the site's **published** future sigla (`lectio update`) and reading them offline *is* in scope — it is the "harvest" path, distinct from calendar computation. ## Key decisions | Decision | Choice | |---|---| | Language / stack | Go; Bubble Tea + Lipgloss (TUI), go-toml/v2 (config). Match bread-calc. | | Binaries | `lectio` (CLI), `lectio-ui` (TUI). | | Self-contained | Embed all four Bible corpora via `go:embed`; verse lookup in Go. No external `vul`/`grb`/`wuj`/`drb` tools. | | Daily data | Still fetched from niedziela.pl (source of citations + Polish text); cached on disk. | | CLI shape | Subcommands (hand-rolled dispatch, no cobra). | | TUI shape | Reader + version-switch: full day in one pane, `tab` cycles versions, arrows change date. | | Config | `~/.config/lectio/config.toml`, auto-seeded, flags override. | | Versification | Port `psalm_versify.py` behaviour exactly. | ## Project layout Module: `github.com/lukaszkasprzak/lectio`. Location: `~/git/projects/lectio`. ``` cmd/lectio/main.go -> cli.Run(args, stdin, stdout, stderr) int cmd/lectio-ui/main.go -> load config+data, tui.New(...), tea.NewProgram(...).Run() internal/liturgy/ Section type; fetch + parse niedziela.pl; cache; harvest/offline; Load router internal/tradlit/ missalemeum API: fetch + parse 1962 propers into []Section internal/bible/ embedded corpora + reference lookup + book aliases internal/psalter/ psalm versification (port of psalm_versify.py) internal/render/ text rendering: compare columns, section text (CLI) internal/config/ TOML config load + auto-seed internal/cli/ subcommand dispatch internal/tui/ Bubble Tea reader Makefile, README.md, LICENSE, go.mod ``` Each `internal/*` package has one clear purpose, a small exported surface, and is unit-testable in isolation. CLI and TUI are thin front-ends over the same core (liturgy + bible + psalter + render). ## Data architecture ### Embedded corpora `internal/bible` embeds four TSV files with `go:embed`: - `wuj.tsv` — Biblia Wujka (Polish), from `offline_readings/corpus/wuj.tsv`. - `drb.tsv` — Douay-Rheims (English), from `offline_readings/drb/drb.tsv`. - `vul.tsv` — Vulgate (Latin), extracted from the installed `vul` tool (`sed '1,/^#EOF$/d' $(command -v vul) | tar xzf - -O vul.tsv`). - `grb.tsv` — Greek, extracted from the installed `grb` tool the same way. All four share the 6-column format `Book | Abbrev | BookNum | Chapter | Verse | Text` (the kjv-family format). Total ~15 MB, embedded into the binary. ### Reference lookup (replaces the awk engine + shell-out) `internal/bible` reimplements the reference grammar in Go. It must support the subset the tool actually feeds it: - Book match: canonical English name (`John`), English prefix (`Joh`), and the Polish/English alias table (`J`, `Łk`, `1 Kor`, `Jana`, `Mdr`, ...) resolved to a canonical book. Longest alias wins; exact before prefix (fixing the old `J`→Joshua bug). - Chapter+verse forms: `Book C:V`, `Book C:V-V` (range), `Book C:V,V,...` (list). Mixed lists like `John 20:1,11-18` are split into single-group queries and merged (port of `split_ref`). - Returns `[]Verse{Chapter, Verse, Text}` in reference order; reports the groups a corpus lacked (for the "brak w …" note). ### Daily readings `internal/liturgy`: - `Fetch(date, refresh) (html, error)` — GET `https://niezbednik.niedziela.pl/liturgia/{date}/Ewangelia` with a browser User-Agent. Cache only fully-published pages. - `Parse(html) ([]Section, error)` — pick the **new** lectionary tab (`tabnowy0all`), fall back to `tabstary0all` with a warning; extract each section's heading, subtitle, citation, and paragraphs. Fail loudly if the tab is present but empty or absent (layout change). - `Section{Heading, Subtitle, Citation, PartID string; Paragraphs [][]string}` (shared by both lectionaries; `PartID` drives part-filtering — see Config). - `Load(cfg, opts) ([]Section, error)` routes to this modern source or to `tradlit` by `cfg.Lectionary`, then applies part-filtering. ### Caching (fast repeat loads) Two layers under `~/.cache/lectio/` (honor `XDG_CACHE_HOME`): - Raw HTML `{date}.html` — skips the network re-fetch of a published page. - Parsed sections `{date}.json` — skips re-parsing; a repeat load the same day hits this and is near-instant (no network, no parse). Only fully-published pages are cached (an unpublished future date keeps being retried, never cached as "empty"). `--refresh` bypasses both layers. `Load(date)` tries JSON → HTML(+parse, write JSON) → fetch(+cache both). ### Sigla harvest (`lectio update`) and offline use The site publishes each day's reading **sigla** (the scripture citations) weeks to months ahead. `lectio update` walks forward from today, fetching each date and extracting its citations, until it reaches the unpublished horizon (a page with no readings). It writes them to a persistent TSV: ~/.local/share/lectio/sigla.tsv (honor XDG_DATA_HOME) One row per reading section: `date section_label citation`, e.g. 2026-07-22 1. czytanie Pnp 8, 6-7 2026-07-22 Psalm Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab) 2026-07-22 Ewangelia J 20, 1. 11-18 `update` merges with the existing file (re-harvesting a date replaces its rows), warms the HTML/JSON cache for those dates, and reports how many days it added and the furthest date reached. **Offline use.** When offline (config `offline = true`, the `--offline` flag, or an automatic fallback after a failed fetch), `Load` reads the sigla TSV instead of the network. If the date is present it builds the sections from the stored citations and renders the four **embedded** Bible versions from the corpora — so after one `lectio update`, any harvested day reads fully offline in Wujek Polish, Latin, Greek and Douay-Rheims. The `pl` version (modern niedziela.pl / Biblia Tysiąclecia) is online-only and not embedded (copyright); **offline, `wuj` (Wujek) takes the Polish role** — `pl` is dropped from any version list and replaced by `wuj` where needed (see Config → `offline`). A date absent from the harvest gives a clear "run `lectio update` online" error. ## Lectionaries: new vs traditional The `lectionary` config selects the source; both produce a `[]Section`, so the render/CLI/TUI/cache layers are shared. - **`new`** (default) — the modern Ordinary-Form readings from niezbednik.niedziela.pl (`internal/liturgy`), as described above. - **`traditional`** — the 1962-Missal propers from the missalemeum API (`internal/tradlit`): `GET https://www.missalemeum.com/{lang}/api/v5/proper/{date}`, `lang` = `traditional_lang` (`pl` or `en` — the only two the API serves). The JSON is `[{info{title,...}, sections:[{id,label,body:[["...text..."]]}]}]`. Each section becomes a `Section{Heading:label, PartID:lower(id), Paragraphs, Citation}`; the citation is extracted from the `*Book c:v*` marker embedded in a reading's body. Section ids: `Introitus, Oratio, Lectio, Graduale, Evangelium, Offertorium, Secreta, Communio, Postcommunio` (plus Alleluia/Tractus/commemorations). **How the versions apply.** A traditional `Section` carries missalemeum's vernacular text as its `Paragraphs` (the `pl`-role source text) and, for scripture propers, a `Citation`. So the scripture parts (Introit, Epistle, Gradual, Gospel, Offertory, Communion) flow through the *same* version-compare as the modern mode — `vul` (Latin), `wuj`, `grb`, `drb` looked up from the embedded corpora by citation. The prayer parts (Collect, Secret, Postcommunion) have no citation, so they render only the missalemeum vernacular. **Latin caveat.** The missalemeum v5 API returns vernacular only (en/pl) — no Latin. Latin for the *readings* comes from the embedded `vul` corpus via the citation; Latin for the *prayers* is not available and those show vernacular only. This is a documented limitation, surfaced with a short note on prayer sections. **Caching / offline.** Traditional pages cache like modern ones (`{date}.json` under a `traditional/` subdir so the two lectionaries don't collide). `lectio update` harvests whichever lectionary is active into its own sigla file (`sigla.tsv` / `sigla-traditional.tsv`); offline builds the scripture-part sections from the harvested citations. Prayer text is not harvested (it is not a citation), so offline traditional shows the readings only. ## Versions and psalm systems Ported from `ewangelia.py` constants: ``` versions: pl, wuj, vul, grb, drb (canonical order; = config `versions` default) labels: pl="Polski (niedziela.pl)" wuj="Wujek (pol.)" vul="Wulgata (lac.)" grb="Grecki" drb="Douay-Rheims (ang.)" bible tools: wuj, vul, grb, drb (pl comes from the fetched paragraphs) psalm system: vul/grb/wuj -> "vulgate" drb -> "drb" ``` `lectio show VERSION` accepts any of the five (pl renders the fetched paragraphs; the others render looked-up verses). `pl` renders the fetched Polish paragraphs (dropping the "Słowa Ewangelii" incipit, and de-duplicating a repeated responsorial refrain). The four bible versions render verses looked up from the embedded corpora, with the citation converted per that version's psalm system. ## Reference conversion & psalm versification `internal/bible` (conversion) + `internal/psalter` (versification) port `to_english_ref` / `_psalm_ref` / `psalm_versify.py`: - Strip a leading `por.` (compare marker) and a trailing `(R.: ...)` responsorial refrain from the citation. - Convert the Polish citation to English style and normalise verse groups (`Mt 7, 1-5` -> `Mat 7:1-5`; disjoint groups preserved as a comma list). - Psalms: the lectionary cites `Ps H (V)`. The Vulgate versions use chapter `V`, verses unchanged. `drb` uses chapter `H` and shifts each verse by the title-fold count `k` for that psalm: `drb_verse = lectionary_verse - k` (clamped ≥ 1). `k` comes from the `DRB_TITLE_FOLD` table (1 or 2 title lines; 0 for untitled), derived by aligning the Wujek/DRB corpora. ~140 psalms exact, ~10 approximate (documented in the package). ## CLI surface (`internal/cli`) Hand-rolled subcommand dispatch. `lectio` with no args = `lectio today`. ``` lectio today [--all] [--raw] [--width N] [--refresh] lectio date D [--all] [--raw] [--width N] [--refresh] # D = YYYY-MM-DD lectio compare LIST [--date D] [--all] [--width N] [--refresh] LIST = comma versions (default: config `versions`) lectio show VERSION [--date D] [--all] [--refresh] # one version's text lectio update [--days N] [--from D] # harvest future sigla to the TSV lectio --version | lectio -v lectio help | lectio -h | lectio -h ``` - `--all` shows every reading (1st/2nd, psalm, acclamation, gospel); default is the gospel only, unless config `all = true`. - `--raw` drops banner/headings for piping. - `--refresh` bypasses the cache and re-fetches. - `--offline` (global) skips the network and uses the sigla TSV; also applied automatically when a fetch fails. - `--lectionary new|traditional` and `--lang pl|en` (global) override the config `lectionary` / `traditional_lang` for one run. - `update` harvests forward from today (or `--from D`) up to `--days N` (default: until the unpublished horizon), writing the sigla TSV. - Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error. ## TUI (`internal/tui`) Bubble Tea Elm architecture, per the approved mockup (reader + version-switch): - One scrolling pane showing the whole day's readings for the **active version**. - Header: date + active version label. Footer: keybar. - Keys: `tab`/`shift+tab` cycle versions (order = config `versions`, starting at `default_version`); `←/→` change date (async re-fetch with a loading state); `j/k` and `space`/`b` scroll; `g/G` top/bottom; `r` refresh; `q`/`ctrl+c` quit. - Offline (config/flag): the version cycle omits `pl` (uses `wuj` for Polish); dates come from the harvest; a header hint shows the offline state. - Async fetch via `tea.Cmd` returning a `readingsMsg` or `errMsg`; a spinner or "ładowanie…" line while in flight. - Lipgloss styling, theme-neutral (works on light/dark terminals); width-aware wrapping from `tea.WindowSizeMsg`. - `Model{ config, cache, date, version, sections, scroll, width, loading, err }`. The TUI shows one version at a time (not columns); the CLI `compare` is where side-by-side lives. ### Colour scheme Colours distinguish the parts of a reading. Each role is a named Lipgloss style in one place (`internal/tui`), using `lipgloss.AdaptiveColor` so it reads on both light and dark terminals; a `NO_COLOR` env / non-TTY output degrades to plain. | Role | Style | |---|---| | Section heading (`1. czytanie`, `Ewangelia`) | bold, accent colour | | Citation (`Pnp 8, 6-7`) | dim / muted, next to the heading | | Verse number (`20:1`) | distinct muted colour, separated from text | | Verse text | default foreground | | Responsorial refrain (psalm) | italic / secondary colour | | Header (date + active version) | accent background or bold | | Footer keybar | dim | Exact hues are finalised in planning, but the role → style mapping above is fixed. The CLI stays plain text (colour is a TUI concern). ## Web UI (`lectio-web`, `internal/web`) A third binary — an `hledger-web`-style local companion. Run `lectio-web`; it starts a local HTTP server and opens the browser at `http://localhost:` (`web_port`, 0 = auto-pick). Pure Go, everything embedded (`go:embed`), still a self-contained single binary — no cgo, no build step. - **Interactivity: HTMX.** Server-rendered `html/template` pages; controls carry `hx-get`/`hx-post` and swap only the reading pane via handlers that return HTML partials. HTMX (~14 KB) is embedded. State lives in URL query params (bookmarkable, back-button works). No SPA, no npm. - **UI.** A top bar with: date `←/→` + date picker; lectionary toggle (new/traditional); version checkboxes (pl/wuj/vul/grb/drb) driving the compare columns; all-parts vs gospel toggle; a **theme** selector; and a passage lookup box (`bible.Lookup` over the embedded corpora — type `J 20:1`, pick versions, see it). The main pane shows the day's readings as one version or responsive compare columns. - **Themes.** Minimal, ascetic (spare typography, generous whitespace, no gradients/shadows/rounded chrome — just the palette applied to the role classes). Original themes evoking religious orders / spiritual traditions, switchable live and defaulted by `web_theme`: - `transfiguration` — the user's own palette (dark hill-green, parchment text, gold halos; canonical hex in `~/git/transfiguration-themes/palettes/transfiguration.json`). Default. - `desert_fathers` — light: sun-bleached sand and ochre, austere (Egyptian desert hermits). - `benedictines` — dark: near-black habit, illuminated gold, parchment (ora et labora). - `franciscans` — warm undyed-brown habit with olive/creation greens (Il Poverello). - `memento_mori` — stark greyscale, ash and bone, one dried-blood accent (contemplative austerity). - `camedules` — light: white habit, cool slate-blue and sage, serene (Camaldolese hermits). And a set following the **liturgical seasons** and their proper colours: - `advent` — violet, contemplative preparation. - `nativity` — white and gold, the light of Christmas (light). - `lent` — ashen, desaturated violet, penitential. - `easter` — radiant white and gold, the Paschal glory (light). - `pentecost` — the red flame of the Spirit, gold tongues of fire. - `ordinary` — green, growth and hope (light). **Colour only — no gimmicks.** A theme file sets ONLY colour values for the role variables/classes (heading / citation / verse number / refrain / body / links / borders). No background images, no per-theme fonts, no ornaments, animations, or effects. All layout, typography and spacing live once in the shared `base.css`; themes never touch them. The active theme is stored in a cookie; `web_theme` is the initial default (ships as `transfiguration`). - **User themes.** Others can add their own: any `*.css` in `${XDG_CONFIG_HOME:-~/.config}/lectio/themes/` becomes a selectable theme named after its filename stem (a user file overrides a built-in of the same name). A custom theme only needs to define the documented role classes. So `web_theme` may name a built-in or a user theme; an unknown name falls back to the default at serve time with a warning (not a hard config error). - **Reuse.** Consumes the same core: `readings.Load` → `[]liturgy.Section`, `render.GatherVersion`'s `(label, blocks)` fed into templates. No changes to the domain packages — `internal/web` imports them, never the reverse. ## Config (`internal/config`) `Config` struct loaded via go-toml. Resolution: `LECTIO_CONFIG` env → `~/.config/lectio/config.toml` (auto-seeded from an embedded default on first run, honoring `XDG_CONFIG_HOME`) → built-in defaults. Flags override. ```toml schema_version = 1 lectionary = "new" # "new" (niedziela.pl) or "traditional" (missalemeum, 1962) traditional_lang = "pl" # vernacular for traditional propers: "pl" or "en" versions = ["pl", "wuj", "vul", "grb", "drb"] # compare set + TUI cycle order default_version = "pl" # TUI start / `lectio show` default width = 0 # CLI wrap width; 0 = detect terminal all = false # default to all parts (true) or just the gospel (false) offline = false # true = never fetch; read only harvested sigla + cache web_theme = "transfiguration" # built-in order/season theme or a user theme in ~/.config/lectio/themes/ (see docs/THEMES.md) web_port = 0 # lectio-web port; 0 = auto-pick a free port # Which parts to show. Both tables are commented out -> every part is shown. # Uncomment a table and set a part to false to hide it; parts you don't list # stay shown. (Parsed as a map: a part is hidden only if explicitly false.) # # [parts.new] # pierwsze_czytanie = true # 1. czytanie (1st reading) # psalm = true # Psalm # drugie_czytanie = true # 2. czytanie (2nd reading, on feasts) # aklamacja = true # Aklamacja (acclamation) # ewangelia = true # Ewangelia (gospel) # # [parts.traditional] # introitus = true # Introit # oratio = true # Collect # lectio = true # Epistle # graduale = true # Gradual / Alleluia / Tract # evangelium = true # Gospel # offertorium = true # Offertory # secreta = true # Secret # communio = true # Communion # postcommunio = true # Postcommunion ``` `Config` fields: `SchemaVersion int`, `Lectionary string`, `TraditionalLang string`, `Versions []string`, `DefaultVersion string`, `Width int`, `All bool`, `Offline bool`, and `Parts map[string]map[string]bool` (the `[parts.new]` / `[parts.traditional]` tables). `Lectionary` must be `new` or `traditional`; `TraditionalLang` must be `pl` or `en`; unknown versions/values are rejected with a clear error. Invalid TOML falls back to defaults with a stderr warning. **Part filtering.** `Config.PartShown(lectionary, partID string) bool` returns `true` unless `Parts[lectionary][partID]` is present and `false`. So a commented (empty) `Parts` shows everything, and a user hides a part by uncommenting the table and setting just that part to `false`. Part IDs: for `new`, `pierwsze_czytanie`/`psalm`/`drugie_czytanie`/`aklamacja`/`ewangelia` (matched from the niedziela heading); for `traditional`, the missalemeum section ids lower-cased (`introitus`, `oratio`, `lectio`, `graduale`, `evangelium`, `offertorium`, `secreta`, `communio`, `postcommunio`). Filtering applies whenever all parts are in play (`--all`, `compare`, the TUI); the gospel-only default (`all = false`) always shows just the gospel / `evangelium`. `offline = true` (or the `--offline` flag) makes lectio work purely from the `lectio update` harvest and cache — it never touches the network, and `pl` (online-only) is dropped in favour of `wuj` as the Polish version: - Any version list drops `pl`; if the list had `pl` but not `wuj`, `wuj` takes its place. So default `versions` become `["wuj","vul","grb","drb"]` offline. - `default_version`/`show pl` fall back to `wuj` offline. - A date not present in the sigla harvest gives a clear "not harvested; run `lectio update` online" error. Seeded default is `offline = false` (a fresh install has no harvest yet); a user who runs `lectio update` on a schedule can flip it to `true` for a fast, network-free daily read. ## Porting map (Python → Go) | Python (`ewangelia.py` / `psalm_versify.py`) | Go | |---|---| | `fetch`, `parse_sections`, `html_to_lines`, `extract_reference` | `internal/liturgy` | | awk engine, `run_bible_tool`, `split_ref` | `internal/bible` (lookup) | | `POLISH_TO_EN`, `to_english_ref`, `_psalm_ref` | `internal/bible` (aliases + conversion) | | `psalm_versify.py` (`DRB_TITLE_FOLD`, `drb_verse`, chapter map) | `internal/psalter` | | `gather_version`, `run_bible_section`, `render_compare`, `render_section`, refrain dedup | `internal/render` | | `VERSION_LABELS`, `PSALM_SYSTEM`, `COMPARE_CODES`, `BIBLE_TOOLS` | `internal/bible` / `internal/render` constants | | `argparse` main | `internal/cli` | ## Error handling - Network failure: fall back to the sigla TSV (offline mode) if the date is harvested — render the four embedded versions, note that `pl` is unavailable. If the date is not harvested either, clear stderr message, exit 1 (CLI) or an error line in the TUI (stay usable, let the user change date). - Unpublished future date: "no reading published for this date yet", exit 1; `update` treats it as the horizon and stops. - Layout change (tab missing/empty): explicit "site layout may have changed" error, exit 1. - A corpus lacking a passage (e.g. deuterocanonical book absent from a version): per-section note, not a crash; other versions still render. - Unknown version code: usage error, exit 2. ## Testing - **bible**: table tests for book matching (incl. `J`→John, `Łk`→Luke, `1 Kor`), reference grammar (ranges, lists, split groups), and known verses across all four corpora (Gen 1:1, John 20:1, a deuterocanonical, a psalm). - **psalter**: unit tests for `drb_verse` on titled/untitled/2-line-title psalms. - **liturgy.Parse**: fixture tests against a few cached HTML pages (a normal day, a split-reading feast, an unpublished date), asserting sections/citations. - **tradlit.Parse**: fixture test against a saved missalemeum JSON response (2026-07-22), asserting the proper sections, part ids, the extracted Gospel citation, and vernacular paragraphs. - **config.PartShown**: a commented (empty) `Parts` shows all; `{new:{psalm:false}}` hides only the psalm and keeps the rest shown. - **liturgy cache + sigla**: round-trip the parsed JSON cache; harvest sigla from fixture pages into a temp TSV and read them back; offline `Load` builds sections from the TSV and renders the embedded versions with `pl` noted unavailable. - **render**: golden-text tests for a compare block and a section, including the refrain dedup and psalm-number divergence. - **config**: load/seed/override tests with a temp `XDG_CONFIG_HOME`. - `go vet ./...` clean; `gofmt`-formatted. ## Build & install Makefile cloned from bread-calc: ``` make build -> ./lectio and ./lectio-ui make install -> $(PREFIX)/bin (default ~/.local/bin) make cross -> dist/ for linux/darwin/windows amd64+arm64 make test | vet | fmt | clean ``` `.gitignore`: `/lectio`, `/lectio-ui`, `dist/`, `*.test`, coverage, cache. ## Open items (decide during planning) - Exact Lipgloss hues for the colour roles (mapping is fixed in the TUI section; only the specific colours are open). - Whether `lectio show` and the TUI share a single "gather one version" function (they should). - Whether to vendor the extracted `vul.tsv`/`grb.tsv` into the repo or fetch them at build time (lean: vendor them under `internal/bible/`, like wuj/drb).