From a675a983424f1f8d103fc12d55ef4f7129a92d62 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 13:07:37 +0200 Subject: qol: tui jump-to-date (d) + lectio --citation/--week + hide bt for traditional web; v0.10.0 --- internal/cli/cli.go | 94 ++++++++++++++++++++++++++++++++++++++- internal/cli/cli_test.go | 67 ++++++++++++++++++++++++++++ internal/config/config.go | 2 +- internal/i18n/i18n.go | 9 +++- internal/i18n/i18n_test.go | 4 +- internal/tui/tui.go | 55 ++++++++++++++++++++++- internal/tui/tui_test.go | 40 +++++++++++++++++ internal/web/server_test.go | 20 +++++++++ internal/web/templates/index.html | 4 +- 9 files changed, 286 insertions(+), 9 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 76ebd5b..611ce52 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -43,6 +43,8 @@ Flags: -P, --pager page reading output (like git); default from config --no-pager never page, even if config sets one --list list all books + abbreviations (dialect from sigla_style) + --citation print the day's gospel reference (scripts/cron) and exit + --week list the coming week's gospel references and exit -v, --version print the version and exit -h, --help this help @@ -59,6 +61,8 @@ Examples: lectio -p "Jn 3:16" -b vul look up a passage (English sigla) lectio -p "J 3,16" -c wuj,drb Polish sigla when sigla_style=polish/auto+pl UI lectio --list list every book + abbreviations + lectio --citation today's gospel reference + lectio --week 2026-07-22 a week of gospel references from a date Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang). @@ -96,7 +100,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } - var all, raw, refresh, offline, update, clean, pagerFlag, noPager bool + var all, raw, refresh, offline, update, clean, pagerFlag, noPager, citation, week bool var bibleVer, compareList, lectionary, lang string var width int var list bool @@ -134,6 +138,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs.StringVar(&ref, "p", "", "look up a passage, e.g. \"J 3:16\" (with -b/-c)") fs.StringVar(&ref, "ref", "", "look up a passage, e.g. \"J 3:16\" (with -b/-c)") fs.BoolVar(&list, "list", false, "list all books + abbreviations (in your sigla_style)") + fs.BoolVar(&citation, "citation", false, "print the day's gospel reference and exit") + fs.BoolVar(&week, "week", false, "list the coming week's gospel references and exit") if err := fs.Parse(rest); err != nil { return 2 @@ -175,6 +181,13 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { cfg.TraditionalLang = lang } + if citation { + return runCitation(cfg, date, refresh, stdout, stderr) + } + if week { + return runWeek(cfg, date, refresh, stdout, stderr) + } + effAll := all || cfg.All effWidth := width if effWidth == 0 { @@ -250,6 +263,85 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return code } +// gospelSection returns the gospel section from a (gospel-only, All=false) +// load: the section tagged "ewangelia" (modern) or "evangelium" (traditional), +// else the first section present. ok is false only when secs is empty. +func gospelSection(secs []liturgy.Section) (liturgy.Section, bool) { + for _, s := range secs { + if s.PartID == "ewangelia" || s.PartID == "evangelium" { + return s, true + } + } + if len(secs) > 0 { + return secs[0], true + } + return liturgy.Section{}, false +} + +// gospelCitation returns a section's scripture reference: its Citation field if +// set, else the reference parsed out of its Heading (e.g. "Ewangelia (Mt 7, +// 1-5)" -> "Mt 7, 1-5"), else "" (source-form, never translated). +func gospelCitation(sec liturgy.Section) string { + if sec.Citation != "" { + return sec.Citation + } + if c, err := liturgy.ExtractCitation(sec.Heading); err == nil { + return c + } + return "" +} + +// runCitation handles --citation: fetch the day's gospel (gospel-only) and +// print just its scripture reference, for scripts/cron/prompt use. Honors the +// resolved cfg (lectionary/lang/offline) and date. Exit 1 if the day has no +// gospel reference (unpublished date, no network while offline, ...). +func runCitation(cfg config.Config, date string, refresh bool, stdout, stderr io.Writer) int { + secs, _, err := readings.Load(cfg, readings.Options{Date: date, Refresh: refresh, Offline: cfg.Offline, All: false}) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 1 + } + sec, ok := gospelSection(secs) + if !ok { + fmt.Fprintln(stderr, "lectio: no gospel for", date) + return 1 + } + cit := gospelCitation(sec) + if cit == "" { + fmt.Fprintln(stderr, "lectio: no gospel reference for", date) + return 1 + } + fmt.Fprintln(stdout, cit) + return 0 +} + +// runWeek handles --week: print the gospel reference for each of the seven days +// starting at date, one "YYYY-MM-DD " line per day. A day that +// can't be loaded (unpublished, offline gap, no gospel) shows "—" rather than +// aborting the run, so the list is always seven lines. Honors cfg +// (lectionary/lang/offline). +func runWeek(cfg config.Config, date string, refresh bool, stdout, stderr io.Writer) int { + start, err := time.Parse("2006-01-02", date) + if err != nil { + fmt.Fprintln(stderr, "lectio:", err) + return 2 + } + for i := 0; i < 7; i++ { + d := start.AddDate(0, 0, i).Format("2006-01-02") + cit := "—" + secs, _, err := readings.Load(cfg, readings.Options{Date: d, Refresh: refresh, Offline: cfg.Offline, All: false}) + if err == nil { + if sec, ok := gospelSection(secs); ok { + if c := gospelCitation(sec); c != "" { + cit = c + } + } + } + fmt.Fprintf(stdout, "%s %s\n", d, cit) + } + return 0 +} + // userBooksTOML returns the bytes of the optional user books.toml, or nil if // it is absent/unreadable (built-in defaults are used). func userBooksTOML() []byte { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bc7dfdb..47d8f55 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -352,3 +352,70 @@ func TestRefCompare(t *testing.T) { t.Errorf("ref compare missing verse:\n%s", out.String()) } } + +func TestGospelCitationHelpers(t *testing.T) { + secs := []liturgy.Section{ + {Heading: "1. czytanie (Iz 1, 1)", PartID: "pierwsze_czytanie"}, + {Heading: "Ewangelia (Mt 7, 1-5)", PartID: "ewangelia"}, + } + sec, ok := gospelSection(secs) + if !ok || sec.PartID != "ewangelia" { + t.Fatalf("gospelSection=%+v ok=%v", sec, ok) + } + if c := gospelCitation(sec); c != "Mt 7, 1-5" { + t.Errorf("citation from heading = %q", c) + } + // Citation field is preferred over the heading. + s2 := liturgy.Section{Heading: "Ewangelia (Mt 7, 1-5)", Citation: "Mt 7, 1-5. 12", PartID: "ewangelia"} + if c := gospelCitation(s2); c != "Mt 7, 1-5. 12" { + t.Errorf("citation prefers Citation field, got %q", c) + } + // Empty -> ("", false). + if _, ok := gospelSection(nil); ok { + t.Error("gospelSection(nil) should be !ok") + } +} + +func TestCitation(t *testing.T) { + html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(html) })) + defer srv.Close() + liturgy.SetBaseURL(srv.URL + "/liturgia/%s/Ewangelia") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var out, errb bytes.Buffer + if code := Run([]string{"--citation", "2026-07-22"}, nil, &out, &errb); code != 0 { + t.Fatalf("citation code=%d stderr=%q", code, errb.String()) + } + if s := strings.TrimSpace(out.String()); !strings.Contains(s, "J 20") { + t.Errorf("citation = %q, want a gospel ref containing 'J 20'", s) + } +} + +func TestWeek(t *testing.T) { + html, err := os.ReadFile("../liturgy/testdata/2026-07-22.html") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(html) })) + defer srv.Close() + liturgy.SetBaseURL(srv.URL + "/liturgia/%s/Ewangelia") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var out, errb bytes.Buffer + if code := Run([]string{"--week", "2026-07-22"}, nil, &out, &errb); code != 0 { + t.Fatalf("week code=%d stderr=%q", code, errb.String()) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 7 { + t.Fatalf("week printed %d lines, want 7:\n%s", len(lines), out.String()) + } + if !strings.HasPrefix(lines[0], "2026-07-22") || !strings.Contains(lines[0], "J 20") { + t.Errorf("week[0] = %q", lines[0]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b17482b..59ab378 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). -const Version = "0.9.0" +const Version = "0.10.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index d9443ea..f0e14ec 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -24,6 +24,9 @@ type UI struct { // error string directly onto them, no separator added at the call site). FooterKeys, Loading, NoReadingsFor, ErrorPrefix, ErrorHint string + // JumpPrompt is the TUI's "d" date-jump prompt label. + JumpPrompt string + // Reader-mode (lectio-ui --reader) chrome. ReaderTitle, ReaderPickKeys, ReaderReadKeys, ReaderNoText, ReaderNoMatch string @@ -83,11 +86,12 @@ var enUI = UI{ "aklamacja": "Acclamation", "ewangelia": "Gospel", }, - FooterKeys: "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit", + FooterKeys: "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom r refresh q quit", Loading: "loading…", NoReadingsFor: "no readings for ", ErrorPrefix: "error: ", ErrorHint: "change date (←/→) or refresh (r)", + JumpPrompt: "go to date (YYYY-MM-DD)", ReaderTitle: "reader — pick a book", ReaderPickKeys: "type to filter ↑/↓ move enter open esc quit", ReaderReadKeys: "n/p chapter tab/⇧tab version j/k scroll space page g/G ends esc books q quit", @@ -134,11 +138,12 @@ var plUI = UI{ "aklamacja": "Aklamacja", "ewangelia": "Ewangelia", }, - FooterKeys: "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście", + FooterKeys: "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście", Loading: "ładowanie…", NoReadingsFor: "brak czytań na ", ErrorPrefix: "błąd: ", ErrorHint: "zmień datę (←/→) lub odśwież (r)", + JumpPrompt: "przejdź do daty (RRRR-MM-DD)", ReaderTitle: "czytnik — wybierz księgę", ReaderPickKeys: "wpisz, by filtrować ↑/↓ ruch enter otwórz esc wyjście", ReaderReadKeys: "n/p rozdział tab/⇧tab wersja j/k przewiń spacja strona g/G końce esc księgi q wyjście", diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 381f87b..79479e8 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -50,10 +50,10 @@ func TestTUIStrings(t *testing.T) { en := Get("en") pl := Get("pl") - if en.FooterKeys != "tab/⇧tab version ←/→ day j/k scroll space/b page g/G top/bottom r refresh q quit" { + if en.FooterKeys != "tab/⇧tab version ←/→ day d date j/k scroll space/b page g/G top/bottom r refresh q quit" { t.Errorf("en.FooterKeys = %q", en.FooterKeys) } - if pl.FooterKeys != "tab/⇧tab wersja ←/→ dzień j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście" { + if pl.FooterKeys != "tab/⇧tab wersja ←/→ dzień d data j/k przewiń spacja/b strona g/G góra/dół r odśwież q wyjście" { t.Errorf("pl.FooterKeys = %q", pl.FooterKeys) } if en.Loading != "loading…" || pl.Loading != "ładowanie…" { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c58db6d..5f7fb52 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -32,6 +32,9 @@ type Model struct { height int loading bool err error + + jumping bool // date-entry ("d") mode is active + jumpBuf string // the date being typed in jump mode } // readingsMsg carries a successful fetch's sections and DayInfo back to @@ -179,6 +182,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + if m.jumping { + return m.updateJump(msg) + } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -200,6 +206,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = true m.err = nil return m, m.fetchCmd(true) + case "d": + m.jumping = true + m.jumpBuf = "" + return m, nil case "j", "down": m.scroll = m.scrollTo(m.scroll + 1) return m, nil @@ -223,6 +233,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// updateJump handles keys while the "d" date-jump prompt is active: digits and +// "-" build the buffer, Enter parses YYYY-MM-DD and navigates (invalid input +// just cancels), Esc cancels, Backspace edits, Ctrl+C quits. Any other key is +// ignored so the prompt stays modal. +func (m Model) updateJump(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.jumping = false + m.jumpBuf = "" + return m, nil + case tea.KeyEnter: + buf := m.jumpBuf + m.jumping = false + m.jumpBuf = "" + if t, err := time.Parse("2006-01-02", buf); err == nil { + m.date = t.Format("2006-01-02") + m.loading = true + m.err = nil + return m, m.fetchCmd(false) + } + return m, nil + case tea.KeyBackspace: + if r := []rune(m.jumpBuf); len(r) > 0 { + m.jumpBuf = string(r[:len(r)-1]) + } + return m, nil + case tea.KeyRunes: + for _, c := range msg.Runes { + if (c >= '0' && c <= '9') || c == '-' { + m.jumpBuf += string(c) + } + } + return m, nil + } + return m, nil +} + // headerLines is how many lines the top header block renders as: 1 (just // the "lectio DATE [version]" bar) or 2 when a day-info line (the // celebration name, optionally with its temporal Season) is shown beneath @@ -295,7 +344,11 @@ func (m Model) View() string { if line := m.dayInfoLine(); line != "" { header += "\n" + line } - footer := footerStyle.Width(w).Render(i18n.Get(m.cfg.UILanguage).FooterKeys) + footerText := i18n.Get(m.cfg.UILanguage).FooterKeys + if m.jumping { + footerText = i18n.Get(m.cfg.UILanguage).JumpPrompt + ": " + m.jumpBuf + } + footer := footerStyle.Width(w).Render(footerText) bodyLines := m.bodyLines(innerW) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 330259d..f99c4ac 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) @@ -161,3 +163,41 @@ func TestFooterKeysLocalised(t *testing.T) { t.Errorf("pl View() footer missing %q: %q", "q wyjście", out) } } + +func TestJumpToDate(t *testing.T) { + send := func(m Model, s string) Model { + for _, r := range s { + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = nm.(Model) + } + return m + } + + m := New(config.Default(), "2026-07-22", "wuj") + m = send(m, "d") + if !m.jumping { + t.Fatal("'d' did not enter jump mode") + } + m = send(m, "2026-01-02") + if m.jumpBuf != "2026-01-02" { + t.Fatalf("jumpBuf = %q", m.jumpBuf) + } + nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = nm.(Model) + if m.jumping { + t.Error("still in jump mode after Enter") + } + if m.date != "2026-01-02" { + t.Errorf("date = %q, want 2026-01-02", m.date) + } + + // Esc cancels without changing the date. + m2 := New(config.Default(), "2026-07-22", "wuj") + m2 = send(m2, "d") + m2 = send(m2, "2099") + nm2, _ := m2.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m2 = nm2.(Model) + if m2.jumping || m2.jumpBuf != "" || m2.date != "2026-07-22" { + t.Errorf("esc did not cancel cleanly: jumping=%v buf=%q date=%q", m2.jumping, m2.jumpBuf, m2.date) + } +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 3bb5ea7..77dfe74 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -232,6 +232,26 @@ func TestServer(t *testing.T) { }) } +// TestBTHiddenForTraditional checks index.html's server-side initial-hidden +// state for the "bt" version checkbox: hidden (inline style) when the +// lectionary is traditional (bt is meaningless for missalemeum -- see +// render.EffectiveVersions), present and visible otherwise. This is +// presentation-only: it does not touch which versions actually load. +func TestBTHiddenForTraditional(t *testing.T) { + srv := NewServer(config.Default()) + trad := httptest.NewRecorder() + srv.ServeHTTP(trad, httptest.NewRequest("GET", "/?lectionary=traditional", nil)) + if !strings.Contains(trad.Body.String(), `id="ver-bt" style="display:none"`) { + t.Errorf("bt checkbox not hidden for traditional") + } + modern := httptest.NewRecorder() + srv.ServeHTTP(modern, httptest.NewRequest("GET", "/?lectionary=new", nil)) + b := modern.Body.String() + if !strings.Contains(b, `id="ver-bt">`) || strings.Contains(b, `id="ver-bt" style="display:none"`) { + t.Errorf("bt checkbox should be visible for modern") + } +} + func TestReaderDefault(t *testing.T) { srv := NewServer(config.Default()) rec := httptest.NewRecorder() diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 26854a8..5dbe7da 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -33,14 +33,14 @@ {{range .VersionOpts}} - + {{end}}