summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 13:07:37 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 13:07:37 +0200
commita675a983424f1f8d103fc12d55ef4f7129a92d62 (patch)
treeb9483bf09f03cb9047a548289556c73a9343bbb0
parent55a5c793d04b55892fcddfc753d8526bd5748f03 (diff)
downloadlectio-a675a983424f1f8d103fc12d55ef4f7129a92d62.tar.gz
lectio-a675a983424f1f8d103fc12d55ef4f7129a92d62.zip
qol: tui jump-to-date (d) + lectio --citation/--week + hide bt for traditional web; v0.10.0
-rw-r--r--internal/cli/cli.go94
-rw-r--r--internal/cli/cli_test.go67
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/i18n/i18n.go9
-rw-r--r--internal/i18n/i18n_test.go4
-rw-r--r--internal/tui/tui.go55
-rw-r--r--internal/tui/tui_test.go40
-rw-r--r--internal/web/server_test.go20
-rw-r--r--internal/web/templates/index.html4
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 <reference>" 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 @@
</span>
<label>{{.L.Lectionary}}
- <select name="lectionary">
+ <select name="lectionary" onchange="var b=document.getElementById('ver-bt');if(b)b.style.display=this.value==='traditional'?'none':'';">
<option value="new" {{if eq .Lectionary "new"}}selected{{end}}>{{.L.OptModern}}</option>
<option value="traditional" {{if eq .Lectionary "traditional"}}selected{{end}}>{{.L.OptTraditional}}</option>
</select>
</label>
{{range .VersionOpts}}
- <label><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>
+ <label id="ver-{{.Code}}"{{if and (eq .Code "bt") (eq $.Lectionary "traditional")}} style="display:none"{{end}}><input type="checkbox" name="v" value="{{.Code}}" {{if .Checked}}checked{{end}}> {{.Code}}</label>
{{end}}
<label>{{.L.Parts}}