diff options
Diffstat (limited to 'internal/web')
| -rw-r--r-- | internal/web/render.go | 160 | ||||
| -rw-r--r-- | internal/web/render_test.go | 23 | ||||
| -rw-r--r-- | internal/web/server.go | 168 | ||||
| -rw-r--r-- | internal/web/server_test.go | 51 | ||||
| -rw-r--r-- | internal/web/static/base.css | 12 | ||||
| -rw-r--r-- | internal/web/templates/index.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/reader.html | 72 |
7 files changed, 453 insertions, 35 deletions
diff --git a/internal/web/render.go b/internal/web/render.go index 9e6280c..35c30b9 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -201,6 +201,46 @@ func interlinearVersions(versions []string) []string { return render.OfflineVersions(versions) } +// verseSet is one version's verses plus its column label, the unit both the +// interlinear readings view and the reader passage view interleave over. +type verseSet struct { + label string + verses []bible.Verse +} + +// interleaveVerses interleaves several versions' verses by chapter:verse: the +// ordered union of keys follows the first set's verse order, then any key only +// a later set has is appended in that set's order (stable, no duplicates). +func interleaveVerses(sets []verseSet) []ilVerseView { + 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}) + } + return vviews +} + // buildInterlinearViews gathers each requested version's verses via // render.GatherVerses (after the bt->wuj substitution, see // interlinearVersions) and interleaves them by chapter:verse: the ordered @@ -214,11 +254,6 @@ func interlinearVersions(versions []string) []string { func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary, lang string) []ilSectionView { mapped := interlinearVersions(versions) - type verseSet struct { - label string - verses []bible.Verse - } - views := make([]ilSectionView, 0, len(secs)) for _, sec := range secs { view := ilSectionView{Heading: render.LocalizeHeading(sec.Heading, sec.PartID, lang), Subtitle: sec.Subtitle, PartID: sec.PartID} @@ -238,40 +273,95 @@ func buildInterlinearViews(secs []liturgy.Section, versions []string, lectionary 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 + view.Verses = interleaveVerses(sets) + views = append(views, view) + } + return views +} + +// webVersionLabel is the localized column label for a version, falling back to +// the bare code (mirrors render's unexported versionLabel, which web can't reach). +func webVersionLabel(v, lang string) string { + if l, ok := i18n.Get(lang).Version[v]; ok { + return l + } + return v +} + +// readerCorpusVersions are the versions the /reader offers: the four with an +// embedded full-text corpus. "bt" (the niedziela.pl scrape) has no corpus and +// cannot be read chapter-by-chapter. +var readerCorpusVersions = []string{"wuj", "vul", "grb", "drb"} + +// UnionChapters returns the sorted union of chapter numbers a book has across +// all corpus versions, so the reader's chapter navigation is stable regardless +// of which versions are currently selected (a version lacking the book just +// shows a note). +func UnionChapters(canonical string) []int { + seen := map[int]bool{} + for _, v := range readerCorpusVersions { + for _, c := range bible.Chapters(v, canonical) { + seen[c] = true } + } + chaps := make([]int, 0, len(seen)) + for c := range seen { + chaps = append(chaps, c) + } + sort.Ints(chaps) + return chaps +} - 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, - }) +// RenderPassage builds the reader pane for one book+chapter across versions, in +// the same three layouts as RenderReadings (columns / vertical / interlinear), +// reusing the very same templates and role classes. name is the book's display +// name in the sigla dialect (the section heading is "<name> <chap>"). versions +// are corpus versions; one lacking the chapter shows a localized "(not in X)" +// note (columns) or is skipped (interlinear). lang localizes the version labels. +func RenderPassage(canonical, name string, chap int, versions []string, display, lang string) template.HTML { + heading := fmt.Sprintf("%s %d", name, chap) + switch display { + case "interlinear": + return renderTemplate("readings-interlinear.html", passageInterlinear(canonical, heading, chap, versions, lang)) + case "vertical": + return renderTemplate("readings-vertical.html", passageColumns(canonical, heading, chap, versions, lang)) + default: + return renderTemplate("readings.html", passageColumns(canonical, heading, chap, versions, lang)) + } +} + +func passageColumns(canonical, heading string, chap int, versions []string, lang string) []sectionView { + cols := make([]columnView, 0, len(versions)) + for _, v := range versions { + verses := bible.Verses(v, canonical, chap) + var blocks []blockView + if len(verses) == 0 { + blocks = append(blocks, blockView{Text: fmt.Sprintf(i18n.Get(lang).NoVersion, v)}) } - view.Verses = vviews - views = append(views, view) + for _, ve := range verses { + blocks = append(blocks, blockView{VNum: fmt.Sprintf("%d:%d", ve.Chapter, ve.Verse), Text: ve.Text}) + } + cols = append(cols, columnView{Label: webVersionLabel(v, lang), Blocks: blocks}) } - return views + return []sectionView{{Heading: heading, PartID: "reader", Columns: cols}} +} + +func passageInterlinear(canonical, heading string, chap int, versions []string, lang string) []ilSectionView { + var sets []verseSet + for _, v := range versions { + verses := bible.Verses(v, canonical, chap) + if len(verses) == 0 { + continue + } + sets = append(sets, verseSet{label: webVersionLabel(v, lang), verses: verses}) + } + view := ilSectionView{Heading: heading, PartID: "reader"} + if len(sets) == 0 { + view.Note = i18n.Get(lang).NoInterlinearVerses + return []ilSectionView{view} + } + view.Verses = interleaveVerses(sets) + return []ilSectionView{view} } // 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 de3c7c1..6002927 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -182,6 +182,29 @@ func TestRenderReadingsLocalizesEN(t *testing.T) { } } +func TestRenderPassageColumns(t *testing.T) { + html := string(RenderPassage("John", "John", 3, []string{"wuj"}, "vertical", "en")) + if !strings.Contains(html, "John 3") || !strings.Contains(html, "3:16") { + t.Errorf("passage columns missing heading/verse:\n%s", html) + } +} + +func TestRenderPassageInterlinear(t *testing.T) { + html := string(RenderPassage("John", "John", 3, []string{"wuj", "vul"}, "interlinear", "en")) + if !strings.Contains(html, "3:16") { + t.Errorf("interlinear missing verse:\n%s", html) + } +} + +func TestRenderPassageMissingVersion(t *testing.T) { + // A non-existent chapter yields no verses: the column must carry the + // "(not in wuj)" note, never a panic or an empty column. + html := string(RenderPassage("John", "John", 999, []string{"wuj"}, "vertical", "en")) + if !strings.Contains(html, "wuj") { + t.Errorf("missing-passage note absent:\n%s", html) + } +} + func TestUserTheme(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) diff --git a/internal/web/server.go b/internal/web/server.go index d8b451a..667e986 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -15,9 +15,11 @@ import ( "os/exec" "regexp" "runtime" + "strconv" "strings" "time" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" @@ -34,10 +36,13 @@ var bibleVersions = []string{"bt", "wuj", "vul", "grb", "drb"} // (lectionary, versions, theme, offline) that requests can override via // query parameters; it is never mutated. func NewServer(cfg config.Config) http.Handler { + tbl, _ := bible.LoadBookTable(config.UserBooksTOML()) + mux := http.NewServeMux() mux.HandleFunc("GET /{$}", indexHandler(cfg)) mux.HandleFunc("GET /readings", readingsHandler(cfg)) + mux.HandleFunc("GET /reader", readerHandler(cfg, tbl)) mux.HandleFunc("GET /theme.css", themeCSSHandler(cfg)) staticSub, err := fs.Sub(staticFS, "static") @@ -282,6 +287,169 @@ func renderOrError(secs []liturgy.Section, versions []string, lectionary, displa return RenderReadings(secs, versions, lectionary, display, lang, dayInfo) } +// requestReaderVersions returns the corpus versions the reader request asks for +// (repeated ?v=), dropping "bt" (no corpus) and anything invalid; falling back +// to the configured default (or "wuj" when that is bt/invalid) so a plain visit +// shows exactly one column. +func requestReaderVersions(cfg config.Config, r *http.Request) []string { + var out []string + for _, v := range r.URL.Query()["v"] { + if v != "bt" && config.ValidVersion(v) { + out = append(out, v) + } + } + if len(out) == 0 { + d := cfg.DefaultVersion + if d == "bt" || !config.ValidVersion(d) { + d = "wuj" + } + out = []string{d} + } + return out +} + +// findBook returns the BookInfo whose Canonical matches, and whether found. +func findBook(books []bible.BookInfo, canonical string) (bible.BookInfo, bool) { + for _, b := range books { + if b.Canonical == canonical { + return b, true + } + } + return bible.BookInfo{}, false +} + +// clampChap keeps chap within the book's available chapters (contiguous in +// practice); 0 when the book has none in any corpus version. +func clampChap(chap int, chaps []int) int { + if len(chaps) == 0 { + return 0 + } + if chap < chaps[0] { + return chaps[0] + } + last := chaps[len(chaps)-1] + if chap > last { + return last + } + return chap +} + +// atoiDefault parses s as an int, returning def when it is empty/unparsable. +func atoiDefault(s string, def int) int { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + return def +} + +type readerData struct { + BookOpts []bookOpt + Chap int + PrevChap, NextChap int + ChapOpts []chapOpt + VersionOpts []versionOpt + Display string + ThemeOpts []themeOpt + Theme string + Mono bool + Reading template.HTML + L i18n.UI + Lang string +} + +type bookOpt struct { + Value string // canonical name (corpus/query value) + Label string // dialect display name + Selected bool +} + +type chapOpt struct { + N int + Selected bool +} + +// readerHandler serves GET /reader: a book picker (dialect names), chapter +// navigation and corpus-version compare, rendering the same reading-pane +// templates/themes as the daily view. The controls form re-fetches /reader and +// hx-selects #reader-root, so book/chapter/version selects stay in sync. +func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + books := tbl.Books(cfg.SiglaLang()) + if len(books) == 0 { // defensive: embedded table always has books + http.Error(w, "web: no books", http.StatusInternalServerError) + return + } + + info, ok := findBook(books, r.URL.Query().Get("book")) + if !ok { + info = books[0] + } + versions := requestReaderVersions(cfg, r) + chaps := UnionChapters(info.Canonical) + first := 1 + if len(chaps) > 0 { + first = chaps[0] + } + chap := clampChap(atoiDefault(r.URL.Query().Get("chap"), first), chaps) + display := requestDisplay(cfg, r) + theme := r.URL.Query().Get("theme") + if theme == "" { + theme = cfg.WebTheme + } + + reading := RenderPassage(info.Canonical, info.Name, chap, versions, display, cfg.UILanguage) + + bookOpts := make([]bookOpt, 0, len(books)) + for _, b := range books { + bookOpts = append(bookOpts, bookOpt{Value: b.Canonical, Label: b.Name, Selected: b.Canonical == info.Canonical}) + } + chapOpts := make([]chapOpt, 0, len(chaps)) + for _, c := range chaps { + chapOpts = append(chapOpts, chapOpt{N: c, Selected: c == chap}) + } + selected := map[string]bool{} + for _, v := range versions { + selected[v] = true + } + versionOpts := make([]versionOpt, 0, len(readerCorpusVersions)) + for _, v := range readerCorpusVersions { + versionOpts = append(versionOpts, versionOpt{Code: v, Checked: selected[v]}) + } + themes := Themes() + themeOpts := make([]themeOpt, 0, len(themes)) + for _, name := range themes { + themeOpts = append(themeOpts, themeOpt{Name: name, Selected: name == theme}) + } + + prev, next := chap-1, chap+1 + if len(chaps) > 0 { + prev = clampChap(chap-1, chaps) + next = clampChap(chap+1, chaps) + } + + data := readerData{ + BookOpts: bookOpts, + Chap: chap, + PrevChap: prev, + NextChap: next, + ChapOpts: chapOpts, + VersionOpts: versionOpts, + Display: display, + ThemeOpts: themeOpts, + Theme: theme, + Mono: queryBool(r, "mono", cfg.WebMono), + Reading: reading, + L: i18n.Get(cfg.UILanguage), + Lang: cfg.UILanguage, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "reader.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + // themeCSSHandler serves one theme's stylesheet: the requested name, or // (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in // default -- so an unrecognized ?name= degrades to a working theme instead diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 809ee59..3bb5ea7 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -232,6 +232,57 @@ func TestServer(t *testing.T) { }) } +func TestReaderDefault(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader", nil)) + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `id="reader-root"`) || !strings.Contains(body, `id="pane"`) { + t.Errorf("reader page missing root/pane") + } + if !strings.Contains(body, "Genesis") { // book <option> label (en dialect) + t.Errorf("book options missing Genesis") + } +} + +func TestReaderPassage(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=wuj", nil)) + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + if b := rec.Body.String(); !strings.Contains(b, "John 3") || !strings.Contains(b, "3:16") { + t.Errorf("passage missing heading/verse") + } +} + +func TestReaderCompareAndBTFilter(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + // bt must be dropped (no corpus); wuj+vul compared side by side. + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=bt&v=wuj&v=vul&display=vertical", nil)) + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + b := rec.Body.String() + if !strings.Contains(b, "display-vertical") { + t.Errorf("vertical compare layout missing") + } +} + +func TestReaderInvalidBook(t *testing.T) { + srv := NewServer(config.Default()) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=Nonsense", nil)) + if rec.Code != 200 { // defaults to the first book, never 500 + t.Errorf("invalid book status %d want 200", rec.Code) + } +} + // TestRenderOrErrorNoSectionsLang checks the "no readings at all for this // day" fragment (finding ยง1) follows lang instead of always being Polish. func TestRenderOrErrorNoSectionsLang(t *testing.T) { diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 1e30b1d..a233e9d 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -94,6 +94,18 @@ body.mono { color: var(--fg); } +/* Reader nav link + chapter stepper โ layout only (themes set colour). */ +.nav-link { + font-family: var(--font-ui); + font-size: 0.85rem; + text-decoration: none; +} +.chap-nav { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + /* Day-info header (feast/day name + optional temporal season): * RenderReadings prepends this once, ahead of every layout's own sections. * It reuses the .heading/.citation role classes (rather than a bespoke diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 780224f..26854a8 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -57,6 +57,8 @@ <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option> </select> </label> + + <a class="nav-link" href="/reader">{{.L.NavReader}} →</a> </form> <label class="theme-picker">{{.L.Theme}} diff --git a/internal/web/templates/reader.html b/internal/web/templates/reader.html new file mode 100644 index 0000000..c82d08e --- /dev/null +++ b/internal/web/templates/reader.html @@ -0,0 +1,72 @@ +{{/* reader.html โ lectio-web Bible reader: pick a book (sigla-dialect names), + navigate chapters, compare corpus versions. The controls form re-fetches + /reader and hx-selects #reader-root (so the book/chapter/version selects + re-render in sync); the theme/mono controls live OUTSIDE #reader-root so + they persist across swaps, exactly like index.html. Reuses the same + reading-pane templates, role classes and themes as the daily view. */}} +<!doctype html> +<html lang="{{.Lang}}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>lectio โ reader</title> +<link rel="stylesheet" href="/static/base.css"> +<link id="theme" rel="stylesheet" href="/theme.css?name={{.Theme}}"> +<script src="/static/htmx.min.js"></script> +</head> +<body{{if .Mono}} class="mono"{{end}}> +<div class="page"> + <div id="reader-root"> + <form class="controls" action="/reader" method="get" + hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root" hx-swap="outerHTML" hx-trigger="change"> + <a class="nav-link" href="/">← {{.L.BannerReadings}}</a> + + <label>{{.L.WebBook}} + <select name="book"> + {{range .BookOpts}}<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>{{end}} + </select> + </label> + + <span class="chap-nav"> + <button type="button" hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root" + hx-swap="outerHTML" hx-vals='{"chap":"{{.PrevChap}}"}'>←</button> + <label>{{.L.WebChapter}} + <select name="chap"> + {{range .ChapOpts}}<option value="{{.N}}" {{if .Selected}}selected{{end}}>{{.N}}</option>{{end}} + </select> + </label> + <button type="button" hx-get="/reader" hx-target="#reader-root" hx-select="#reader-root" + hx-swap="outerHTML" hx-vals='{"chap":"{{.NextChap}}"}'>→</button> + </span> + + {{range .VersionOpts}} + <label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label> + {{end}} + + <label>{{.L.Layout}} + <select name="display"> + <option value="vertical" {{if eq .Display "vertical"}}selected{{end}}>{{.L.OptColumns}}</option> + <option value="horizontal" {{if eq .Display "horizontal"}}selected{{end}}>{{.L.OptHorizontal}}</option> + <option value="interlinear" {{if eq .Display "interlinear"}}selected{{end}}>{{.L.OptInterlinear}}</option> + </select> + </label> + + <div id="pane">{{.Reading}}</div> + </form> + </div> + + <label class="theme-picker">{{.L.Theme}} + <select id="theme-select" + onchange="var o=document.getElementById('theme'),n=o.cloneNode(false);n.setAttribute('href','/theme.css?name='+encodeURIComponent(this.value));o.replaceWith(n);"> + {{range .ThemeOpts}} + <option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Name}}</option> + {{end}} + </select> + </label> + + <label class="mono-toggle"><input type="checkbox" {{if .Mono}}checked{{end}} + onchange="document.body.classList.toggle('mono', this.checked)"> {{.L.Mono}}</label> + +</div> +</body> +</html> |
