From aa6c9dad764089a3f0e287b5f90937e97dde3921 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 23 Jul 2026 15:00:17 +0200 Subject: web: horizontal/vertical/interlinear display modes + web_display config Adds a display-layout option to lectio-web alongside the existing colour themes: - config: WebDisplay field (toml web_display), default "horizontal", normalized on load via the new exported config.NormalizeDisplay (unknown -> "horizontal", same lenient style as the other web_* fields). - render: extracts the citation/ToEnglishRef resolution shared by GatherVersion into an unexported resolveRef helper (GatherVersion's signature/behavior unchanged) and adds GatherVerses(version, sec, lectionary) returning raw bible.Verse structs plus a versified flag, for column/interlinear alignment. - web/render: RenderReadings gains a display parameter. "horizontal" is the original stacked layout, byte-for-byte the same code path as before. "vertical" reuses the same per-version column data in a .display-vertical/.vcol grid (the CLI compare view, browser-side). "interlinear" maps versions through render.OfflineVersions' pl->wuj substitution (pl has no verse numbers), gathers GatherVerses per version, and interleaves them by chapter:verse into .ilverse/.illine blocks (ordered union of keys, first versified version's order first). - server: adds a display ` 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`. + ## 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. diff --git a/internal/config/config.go b/internal/config/config.go index 9c3c3cd..c15b5a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/pelletier/go-toml/v2" ) @@ -30,6 +31,26 @@ var validVersions = map[string]bool{ "drb": true, } +// validDisplays are the lectio-web reading-pane layouts. +var validDisplays = map[string]bool{ + "horizontal": true, + "vertical": true, + "interlinear": true, +} + +// NormalizeDisplay lower-cases display and falls back to "horizontal" when +// it is not one of validDisplays -- the same lenient style as the rest of +// lectio-web's config fields (no error, just a safe default). Exported so +// internal/web can apply the identical normalization to an explicit +// ?display= query override, keeping the valid set in this one place. +func NormalizeDisplay(display string) string { + d := strings.ToLower(display) + if !validDisplays[d] { + return "horizontal" + } + return d +} + // Config holds lectio's user-configurable settings. type Config struct { SchemaVersion int `toml:"schema_version"` @@ -42,6 +63,7 @@ type Config struct { Offline bool `toml:"offline"` WebTheme string `toml:"web_theme"` WebPort int `toml:"web_port"` + WebDisplay string `toml:"web_display"` Parts map[string]map[string]bool `toml:"parts"` } @@ -69,6 +91,7 @@ func Default() Config { Offline: false, WebTheme: "transfiguration", WebPort: 0, + WebDisplay: "horizontal", Parts: nil, } } @@ -134,6 +157,7 @@ func Load() (Config, error) { fmt.Fprintf(os.Stderr, "lectio: warning: invalid config at %s: %v; using defaults\n", path, err) return def, nil } + cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) if err := validate(cfg); err != nil { return Config{}, err diff --git a/internal/config/config.toml b/internal/config/config.toml index 4ad3289..9c379e7 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -8,6 +8,7 @@ all = false # default to all parts (true) or just the gospel (fa 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/ web_port = 0 # lectio-web port; 0 = auto-pick a free port +web_display = "horizontal" # lectio-web layout: "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse) # 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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9b72bc7..9ca311b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,6 +59,46 @@ func TestWebLoadsFromSeed(t *testing.T) { if cfg.WebPort != 0 { t.Errorf("WebPort from seed wrong: got %d, want 0", cfg.WebPort) } + if cfg.WebDisplay != "horizontal" { + t.Errorf("WebDisplay from seed wrong: got %q, want %q", cfg.WebDisplay, "horizontal") + } +} + +func TestWebDisplayDefault(t *testing.T) { + def := Default() + if def.WebDisplay != "horizontal" { + t.Errorf("WebDisplay default wrong: got %q, want %q", def.WebDisplay, "horizontal") + } +} + +func TestWebDisplayLoadsSetting(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("web_display = \"vertical\"\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WebDisplay != "vertical" { + t.Errorf("WebDisplay = %q, want %q", cfg.WebDisplay, "vertical") + } +} + +func TestWebDisplayNormalizesUnknown(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("web_display = \"bogus\"\n"), 0o644) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WebDisplay != "horizontal" { + t.Errorf("WebDisplay = %q, want %q (normalized from bogus)", cfg.WebDisplay, "horizontal") + } } func TestPartShown(t *testing.T) { diff --git a/internal/render/render.go b/internal/render/render.go index 2807272..b8d778c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -55,23 +55,9 @@ func GatherVersion(version string, sec liturgy.Section, lectionary string) (labe return label, gatherPL(sec) } - citation := sec.Citation - if citation == "" { - if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { - citation = c - } - } - if citation == "" { - return label, []string{"(brak odwołania)"} - } - - ref := citation - if lectionary == "new" { - r, err := bible.ToEnglishRef(citation, system(version)) - if err != nil { - return label, []string{fmt.Sprintf("(brak odwołania: %v)", err)} - } - ref = r + ref, err := resolveRef(version, sec, lectionary) + if err != nil { + return label, []string{err.Error()} } verses, missing := bible.Lookup(version, ref) @@ -89,6 +75,55 @@ func GatherVersion(version string, sec liturgy.Section, lectionary string) (labe return label, blocks } +// resolveRef resolves a non-"pl" version's bible.Lookup reference for sec: +// sec.Citation (or, failing that, the citation extracted from sec.Heading), +// converted to English/kjv-style via bible.ToEnglishRef when +// lectionary=="new" (a "traditional" citation is already English-style and +// used as-is). Shared by GatherVersion and GatherVerses so both apply the +// exact same resolution. +func resolveRef(version string, sec liturgy.Section, lectionary string) (string, error) { + citation := sec.Citation + if citation == "" { + if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { + citation = c + } + } + if citation == "" { + return "", fmt.Errorf("(brak odwołania)") + } + + if lectionary != "new" { + return citation, nil + } + ref, err := bible.ToEnglishRef(citation, system(version)) + if err != nil { + return "", fmt.Errorf("(brak odwołania: %w)", err) + } + return ref, nil +} + +// GatherVerses returns one version's verses for a section as raw bible.Verse +// structs (for column/interlinear alignment). versified is false for "pl" +// (paragraph text, no verse numbers) and on any resolution/lookup failure -- +// callers fall back to GatherVersion's string blocks for those. +func GatherVerses(version string, sec liturgy.Section, lectionary string) (label string, verses []bible.Verse, versified bool) { + label = versionLabels[version] + if version == "pl" { + return label, nil, false + } + + ref, err := resolveRef(version, sec, lectionary) + if err != nil { + return label, nil, false + } + + verses, _ = bible.Lookup(version, ref) + if len(verses) == 0 { + return label, nil, false + } + return label, verses, true +} + // gatherPL returns the section's paragraphs, one block per paragraph, with // the liturgical incipit ("Słowa Ewangelii według ...") dropped and repeated // blocks (a responsorial psalm's refrain) deduped to their first occurrence. diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 87f9ed2..1b338a0 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -51,3 +51,34 @@ func TestOfflineVersions(t *testing.T) { } } } + +func TestGatherVersesBible(t *testing.T) { + sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"} + label, verses, versified := GatherVerses("wuj", sec, "new") + if !strings.Contains(label, "Wujek") { + t.Errorf("label = %q", label) + } + if !versified { + t.Error("versified = false, want true for wuj with a resolvable citation") + } + if len(verses) == 0 { + t.Fatal("verses empty") + } + if verses[0].Chapter != 20 || verses[0].Verse != 1 || verses[0].Text == "" { + t.Errorf("verses[0] = %+v", verses[0]) + } +} + +func TestGatherVersesPL(t *testing.T) { + sec := liturgy.Section{ + Heading: "Psalm (Ps 1)", + Paragraphs: [][]string{{"stanza one"}}, + } + _, verses, versified := GatherVerses("pl", sec, "new") + if versified { + t.Error("versified = true, want false for pl (paragraph text, no verse numbers)") + } + if verses != nil { + t.Errorf("verses = %+v, want nil for pl", verses) + } +} diff --git a/internal/web/render.go b/internal/web/render.go index 67c7a37..63c1da4 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -18,6 +18,7 @@ import ( "sort" "strings" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/liturgy" "github.com/lukaszkasprzak/lectio/internal/render" ) @@ -59,13 +60,70 @@ type blockView struct { Refrain bool } -// RenderReadings builds the reading pane: for each section, a heading (and -// subtitle if present) followed by one column per version, each built by -// calling render.GatherVersion(v, sec, lectionary). Heading, citation -// (subtitle), verse-number and refrain text are wrapped in -// class="heading|citation|vnum|refrain" spans so theme CSS can restyle -// them; verse/paragraph text is escaped by html/template. -func RenderReadings(secs []liturgy.Section, versions []string, lectionary string) template.HTML { +// ilSectionView, ilVerseView and ilLineView are what +// templates/readings-interlinear.html ranges over: one ilSectionView per +// liturgy.Section, one ilVerseView per chapter:verse key (in ordered-union +// order, see buildInterlinearViews), one ilLineView per version that carries +// that verse. Note holds a short escaped message in place of Verses when no +// requested version could be interleaved for the section. +type ilSectionView struct { + Heading, Subtitle, PartID string + Verses []ilVerseView + Note string +} + +type ilVerseView struct { + VNum string + Lines []ilLineView +} + +type ilLineView struct { + Label, Text string +} + +// RenderReadings builds the reading pane fragment for one of three layouts: +// +// - "horizontal" (or anything unrecognized): the original stacked layout, +// one column per version rendered under a shared heading, unchanged. +// - "vertical": the same per-version columns side by side in a +// ".display-vertical" grid, like the CLI `compare` view. +// - "interlinear": versions interleaved verse-by-verse by chapter:verse +// (see buildInterlinearViews); "pl" cannot participate (no verse +// numbers) and is substituted/dropped via render.OfflineVersions' +// pl->wuj transform before gathering. +// +// In every mode, heading, citation (subtitle), verse-number and refrain +// text are wrapped in class="heading|citation|vnum|refrain|version-label" +// spans so theme CSS can restyle them; verse/paragraph text is escaped by +// html/template. +func RenderReadings(secs []liturgy.Section, versions []string, lectionary, display string) template.HTML { + switch display { + case "vertical": + return renderTemplate("readings-vertical.html", buildColumnViews(secs, versions, lectionary)) + case "interlinear": + return renderTemplate("readings-interlinear.html", buildInterlinearViews(secs, versions, lectionary)) + default: + return renderTemplate("readings.html", buildColumnViews(secs, versions, lectionary)) + } +} + +// renderTemplate executes the named embedded template with data, degrading +// to a visible, escaped error paragraph on failure (should be unreachable: +// the templates are embedded and fixed at build time) rather than panicking +// a request handler in the caller. +func renderTemplate(name string, data any) template.HTML { + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { + return template.HTML("

" + template.HTMLEscapeString(err.Error()) + "

") + } + return template.HTML(buf.String()) +} + +// buildColumnViews gathers each section's per-version columns via +// render.GatherVersion -- the shared data both the "horizontal" +// (readings.html) and "vertical" (readings-vertical.html) templates range +// over; only the surrounding markup differs between the two layouts. +func buildColumnViews(secs []liturgy.Section, versions []string, lectionary string) []sectionView { views := make([]sectionView, 0, len(secs)) for _, sec := range secs { isPsalm := sec.PartID == "psalm" @@ -96,15 +154,89 @@ func RenderReadings(secs []liturgy.Section, versions []string, lectionary string Columns: cols, }) } + return views +} - var buf bytes.Buffer - if err := tmpl.ExecuteTemplate(&buf, "readings.html", views); err != nil { - // Should be unreachable (the template is embedded and fixed at - // build time); degrade to a visible, escaped error rather than - // panicking a request handler in the caller. - return template.HTML("

" + template.HTMLEscapeString(err.Error()) + "

") +// interlinearVersions maps versions through the same pl->wuj substitution +// render.OfflineVersions performs for offline mode: "pl" (niedziela.pl +// paragraph text) carries no verse numbers and cannot interleave, so it is +// dropped, substituting "wuj" (the Polish-language bible version) in its +// place unless "wuj" was already selected. Reuses render.OfflineVersions +// rather than duplicating its two-line transform. +func interlinearVersions(versions []string) []string { + return render.OfflineVersions(versions) +} + +// buildInterlinearViews gathers each requested version's verses via +// render.GatherVerses (after the pl->wuj substitution, see +// interlinearVersions) and interleaves them by chapter:verse: the ordered +// union of keys is taken from the first versified version's own verse +// order, then any keys that only appear in a later version are appended in +// that version's order (stable, no duplicates). A version that comes back +// unversified (a bible lookup miss) is simply skipped -- the remaining +// versions still render. A section where nothing could be interleaved gets +// a short escaped Note instead of an empty Verses list. +func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary string) []ilSectionView { + mapped := interlinearVersions(versions) + + type verseSet struct { + label string + verses []bible.Verse } - return template.HTML(buf.String()) + + views := make([]ilSectionView, 0, len(secs)) + for _, sec := range secs { + view := ilSectionView{Heading: sec.Heading, Subtitle: sec.Subtitle, PartID: sec.PartID} + + var sets []verseSet + for _, v := range mapped { + label, verses, versified := render.GatherVerses(v, sec, lectionary) + if !versified { + continue + } + sets = append(sets, verseSet{label: label, verses: verses}) + } + + if len(sets) == 0 { + view.Note = "(brak wersetów do zestawienia interlinearnego)" + views = append(views, view) + continue + } + + type vkey struct{ chapter, verse int } + order := make([]vkey, 0) + seen := map[vkey]bool{} + bySet := make([]map[vkey]bible.Verse, len(sets)) + for i, s := range sets { + m := make(map[vkey]bible.Verse, len(s.verses)) + for _, v := range s.verses { + k := vkey{v.Chapter, v.Verse} + m[k] = v + if !seen[k] { + seen[k] = true + order = append(order, k) + } + } + bySet[i] = m + } + + vviews := make([]ilVerseView, 0, len(order)) + for _, k := range order { + var lines []ilLineView + for i, s := range sets { + if v, ok := bySet[i][k]; ok { + lines = append(lines, ilLineView{Label: s.label, Text: v.Text}) + } + } + vviews = append(vviews, ilVerseView{ + VNum: fmt.Sprintf("%d:%d", k.chapter, k.verse), + Lines: lines, + }) + } + view.Verses = vviews + views = append(views, view) + } + return views } // Themes returns the sorted, deduplicated union of the embedded theme diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 7ee666a..974c6f1 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -11,7 +11,7 @@ import ( func TestRenderReadings(t *testing.T) { secs := []liturgy.Section{{Heading: "Ewangelia (J 20, 1. 11-18)", PartID: "ewangelia"}} - html := string(RenderReadings(secs, []string{"wuj"}, "new")) + html := string(RenderReadings(secs, []string{"wuj"}, "new", "horizontal")) if !strings.Contains(html, "Ewangelia") || !strings.Contains(html, "class=") { t.Errorf("reading pane missing heading/classes: %q", html[:min(200, len(html))]) } @@ -45,7 +45,7 @@ func TestRenderReadingsEscapesScriptText(t *testing.T) { PartID: "pierwsze_czytanie", Paragraphs: [][]string{{""}}, }} - html := string(RenderReadings(secs, []string{"pl"}, "new")) + html := string(RenderReadings(secs, []string{"pl"}, "new", "horizontal")) if strings.Contains(html, "") { t.Errorf("raw