aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 10:47:48 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 10:47:48 +0200
commite50b003d84b091b60c8028468edc633f04e39731 (patch)
treef68b4efc14b133bacd571550d86869aa450cf97d
parentc9f3bf46f0de09edb796e35f3dcff349febf75c9 (diff)
downloadlectio-e50b003d84b091b60c8028468edc633f04e39731.tar.gz
lectio-e50b003d84b091b60c8028468edc633f04e39731.zip
bible-lookup: add --ref passage lookup + --list-pl/--list-en book lists; v0.6.0
-rw-r--r--internal/bible/books.go45
-rw-r--r--internal/bible/books_test.go40
-rw-r--r--internal/cli/cli.go178
-rw-r--r--internal/cli/cli_test.go62
-rw-r--r--internal/config/config.go2
5 files changed, 314 insertions, 13 deletions
diff --git a/internal/bible/books.go b/internal/bible/books.go
index 045509a..f3af4af 100644
--- a/internal/bible/books.go
+++ b/internal/bible/books.go
@@ -112,3 +112,48 @@ func ResolveBook(query string) (string, bool) {
}
return "", false
}
+
+// canonOrder lists every aliasSeed book in scriptural (Catholic canon) order:
+// 46 Old Testament books then 27 New Testament. It is the ONLY hand-maintained
+// ordering; the display names are still derived from aliasSeed so there is one
+// source of truth for spellings. A test asserts canonOrder and aliasSeed hold
+// exactly the same set.
+var canonOrder = []string{
+ // Old Testament
+ "Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy",
+ "Joshua", "Judges", "Ruth", "1 Samuel", "2 Samuel",
+ "1 Kings", "2 Kings", "1 Chronicles", "2 Chronicles", "Ezra",
+ "Nehemiah", "Tobit", "Judith", "Esther", "1 Maccabees",
+ "2 Maccabees", "Job", "Psalms", "Proverbs", "Ecclesiastes",
+ "Song of Solomon", "Wisdom", "Sirach", "Isaiah", "Jeremiah",
+ "Lamentations", "Baruch", "Ezekiel", "Daniel", "Hosea",
+ "Joel", "Amos", "Obadiah", "Jonah", "Micah",
+ "Nahum", "Habakkuk", "Zephaniah", "Haggai", "Zechariah",
+ "Malachi",
+ // New Testament
+ "Matthew", "Mark", "Luke", "John", "The Acts",
+ "Romans", "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians",
+ "Philippians", "Colossians", "1 Thessalonians", "2 Thessalonians", "1 Timothy",
+ "2 Timothy", "Titus", "Philemon", "Hebrews", "James",
+ "1 Peter", "2 Peter", "1 John", "2 John", "3 John",
+ "Jude", "Revelation",
+}
+
+// BookInfo is one Biblical book's display data for the --list-pl/--list-en
+// commands (and, in later phases, the reader book pickers).
+type BookInfo struct {
+ English string // canonical English name, e.g. "John" (the aliasSeed key)
+ Polish string // Polish name, e.g. "Jan" (aliasSeed value [1])
+ Abbrev string // Polish citation abbreviation, e.g. "J" (aliasSeed value [0])
+}
+
+// Books returns every Biblical book in scriptural order with its English name,
+// Polish name and citation abbreviation, all derived from aliasSeed.
+func Books() []BookInfo {
+ out := make([]BookInfo, 0, len(canonOrder))
+ for _, name := range canonOrder {
+ a := aliasSeed[name]
+ out = append(out, BookInfo{English: name, Polish: a[1], Abbrev: a[0]})
+ }
+ return out
+}
diff --git a/internal/bible/books_test.go b/internal/bible/books_test.go
index 43a67cf..e1a65ca 100644
--- a/internal/bible/books_test.go
+++ b/internal/bible/books_test.go
@@ -24,3 +24,43 @@ func TestResolveBook(t *testing.T) {
t.Error("ResolveBook(Nonsense) should fail")
}
}
+
+func TestBooksTableIntegrity(t *testing.T) {
+ if len(canonOrder) != len(aliasSeed) {
+ t.Fatalf("canonOrder has %d names, aliasSeed has %d", len(canonOrder), len(aliasSeed))
+ }
+ seen := map[string]bool{}
+ for _, name := range canonOrder {
+ if seen[name] {
+ t.Errorf("canonOrder duplicate %q", name)
+ }
+ seen[name] = true
+ a, ok := aliasSeed[name]
+ if !ok {
+ t.Errorf("canonOrder name %q missing from aliasSeed", name)
+ continue
+ }
+ if len(a) < 2 {
+ t.Errorf("book %q has %d aliases, need >=2 (abbrev, pl-name)", name, len(a))
+ }
+ }
+ for name := range aliasSeed {
+ if !seen[name] {
+ t.Errorf("aliasSeed name %q missing from canonOrder", name)
+ }
+ }
+}
+
+func TestBooksResolve(t *testing.T) {
+ for _, b := range Books() {
+ if b.English == "" || b.Polish == "" || b.Abbrev == "" {
+ t.Errorf("incomplete BookInfo %+v", b)
+ }
+ if c, ok := ResolveBook(b.Abbrev); !ok || c != b.English {
+ t.Errorf("abbrev %q resolved to (%q,%v), want %q", b.Abbrev, c, ok, b.English)
+ }
+ }
+ if got := len(Books()); got != 73 {
+ t.Errorf("Books() len = %d, want 73", got)
+ }
+}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 5954e23..bd080b9 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -13,6 +13,7 @@ import (
"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"
@@ -30,6 +31,7 @@ Flags:
-a, --all all readings, not just the gospel
-b, --bible VER one version's text: bt,wuj,vul,grb,drb
-c, --compare LIST versions side by side (comma-separated)
+ -p, --ref REF look up a passage (e.g. "J 3:16") with -b/-c
-r, --raw text only, no banner/headings (for piping)
-w, --width N wrap width; 0 = detect terminal
-R, --refresh ignore cache, re-download
@@ -40,6 +42,8 @@ Flags:
-C, --clean prune cached readings older than a year
-P, --pager page reading output (like git); default from config
--no-pager never page, even if config sets one
+ --list-pl list all books with Polish names + abbreviations
+ --list-en list all books with English names + abbreviations
-v, --version print the version and exit
-h, --help this help
@@ -53,6 +57,9 @@ Examples:
lectio -b vul -a Vulgate text, all readings
lectio -l trad -a traditional lectionary, all readings
lectio -u harvest sigla to the horizon
+ lectio -p "J 3:16" -b vul look up a passage in one version
+ lectio -p "Ps 23:1" -c wuj,drb compare a passage across versions
+ lectio --list-en list every book (English) + abbreviations
Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse),
2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang).
@@ -93,6 +100,8 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var all, raw, refresh, offline, update, clean, pagerFlag, noPager bool
var bibleVer, compareList, lectionary, lang string
var width int
+ var listPL, listEN bool
+ var ref string
fs := flag.NewFlagSet("lectio", flag.ContinueOnError)
fs.SetOutput(stderr)
@@ -123,6 +132,10 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs.BoolVar(&pagerFlag, "P", false, "page reading output (like git)")
fs.BoolVar(&pagerFlag, "pager", false, "page reading output (like git)")
fs.BoolVar(&noPager, "no-pager", false, "never page, even if config sets one")
+ 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(&listPL, "list-pl", false, "list all books with Polish names + abbreviations")
+ fs.BoolVar(&listEN, "list-en", false, "list all books with English names + abbreviations")
if err := fs.Parse(rest); err != nil {
return 2
@@ -178,18 +191,28 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
- // Validate versions before any pager starts -- don't page an error. When
- // compareList != "", bibleVer is unused (compare wins, as before) so it's
- // deliberately left unvalidated in that case.
- if bibleVer != "" && compareList == "" && !config.ValidVersion(bibleVer) {
- fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", bibleVer)
- return 2
- }
- if compareList != "" {
- for _, v := range strings.Split(compareList, ",") {
- if v = strings.TrimSpace(v); v != "" && !config.ValidVersion(v) {
- fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", v)
- return 2
+ // Validate versions before any pager starts -- don't page an error.
+ var refVersions []string
+ if ref != "" {
+ rv, verr := refLookupVersions(cfg, bibleVer, compareList)
+ if verr != nil {
+ fmt.Fprintln(stderr, "lectio:", verr)
+ return 2
+ }
+ refVersions = rv
+ } else {
+ // Day-reading paths: bt is a valid version here (compareList wins over
+ // bibleVer, as before, so bibleVer is left unvalidated when a list is given).
+ if bibleVer != "" && compareList == "" && !config.ValidVersion(bibleVer) {
+ fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", bibleVer)
+ return 2
+ }
+ if compareList != "" {
+ for _, v := range strings.Split(compareList, ",") {
+ if v = strings.TrimSpace(v); v != "" && !config.ValidVersion(v) {
+ fmt.Fprintf(stderr, "lectio: unknown version %q (want one of bt, wuj, vul, grb, drb)\n", v)
+ return 2
+ }
}
}
}
@@ -205,6 +228,10 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var code int
switch {
+ case listPL || listEN:
+ code = runList(listEN && !listPL, out)
+ case ref != "":
+ code = lookupRef(cfg, ref, refVersions, raw, effWidth, out, stderr)
case compareList != "":
code = renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, out, stderr)
case bibleVer != "":
@@ -529,6 +556,133 @@ func renderCompare(cfg config.Config, list, date string, all, raw bool, width in
return 0
}
+// isCorpusVersion reports whether v is a scripture-text version with an
+// embedded corpus (wuj/vul/grb/drb) -- i.e. a valid version that is not "bt"
+// (the niedziela.pl scrape, which has no full text to look a passage up in).
+func isCorpusVersion(v string) bool {
+ return config.ValidVersion(v) && v != "bt"
+}
+
+// refLookupVersions resolves which corpus version(s) `--ref` should look a
+// passage up in, rejecting "bt" (no corpus). Precedence: an explicit -c LIST,
+// then a single -b VER, then a sensible default (the configured default_version
+// if it has a corpus, else the first corpus version in cfg.Versions). It errors
+// (exit 2 in the caller) on any non-corpus version or if nothing usable is
+// configured.
+func refLookupVersions(cfg config.Config, bibleVer, compareList string) ([]string, error) {
+ if compareList != "" {
+ var versions []string
+ for _, v := range strings.Split(compareList, ",") {
+ if v = strings.TrimSpace(v); v == "" {
+ continue
+ }
+ if !isCorpusVersion(v) {
+ return nil, fmt.Errorf("--ref cannot use version %q; pass a corpus version (wuj, vul, grb, drb)", v)
+ }
+ versions = append(versions, v)
+ }
+ if len(versions) == 0 {
+ return nil, fmt.Errorf("--ref needs at least one corpus version in -c")
+ }
+ return versions, nil
+ }
+ if bibleVer != "" {
+ if !isCorpusVersion(bibleVer) {
+ return nil, fmt.Errorf("--ref cannot use version %q; pass a corpus version (wuj, vul, grb, drb)", bibleVer)
+ }
+ return []string{bibleVer}, nil
+ }
+ if isCorpusVersion(cfg.DefaultVersion) {
+ return []string{cfg.DefaultVersion}, nil
+ }
+ for _, v := range cfg.Versions {
+ if isCorpusVersion(v) {
+ return []string{v}, nil
+ }
+ }
+ return nil, fmt.Errorf("--ref needs a corpus version; pass -b wuj|vul|grb|drb")
+}
+
+// lookupRef renders a passage lookup (-p/--ref). A --ref citation is already in
+// target form (a "Book chap:verse" reference), exactly like a traditional
+// (missalemeum) citation, so it reuses the same render path with
+// lectionary="traditional": render.GatherVersion/Compare then resolve the ref
+// literally via bible.Lookup (no Polish->English niedziela conversion). versions
+// is the already-validated corpus set (never "bt"). raw omits the header for
+// piping. Exit 1 if NO requested version yields any verse.
+func lookupRef(cfg config.Config, ref string, versions []string, raw bool, width int, stdout, stderr io.Writer) int {
+ ref = strings.TrimSpace(ref)
+ if ref == "" {
+ fmt.Fprintln(stderr, "lectio: empty --ref")
+ return 2
+ }
+ sec := liturgy.Section{Citation: ref, Heading: ref}
+
+ found := false
+ for _, v := range versions {
+ if vs, _ := bible.Lookup(v, ref); len(vs) > 0 {
+ found = true
+ break
+ }
+ }
+ if !found {
+ fmt.Fprintf(stderr, "lectio: no text found for %q in %s\n", ref, strings.Join(versions, ", "))
+ return 1
+ }
+
+ w := width
+ if w <= 0 {
+ if len(versions) > 1 {
+ w = compareDefaultWidth
+ } else {
+ w = defaultWidth
+ }
+ }
+
+ if !raw {
+ fmt.Fprintln(stdout, ref)
+ fmt.Fprintln(stdout, strings.Repeat("=", minInt(len([]rune(ref)), w)))
+ fmt.Fprintln(stdout)
+ }
+
+ if len(versions) == 1 {
+ label, blocks := render.GatherVersion(versions[0], sec, "traditional", cfg.UILanguage)
+ if !raw {
+ fmt.Fprintln(stdout, label)
+ fmt.Fprintln(stdout)
+ }
+ lines := make([]string, 0, len(blocks))
+ for _, b := range blocks {
+ lines = append(lines, render.Wrap(b, w))
+ }
+ fmt.Fprintln(stdout, strings.Join(lines, "\n"))
+ return 0
+ }
+
+ fmt.Fprintln(stdout, render.Compare([]liturgy.Section{sec}, versions, w, "traditional", cfg.UILanguage))
+ return 0
+}
+
+// runList handles --list-pl/--list-en: print every Biblical book (scriptural
+// order) as "<abbrev> <name>", the name in Polish when pl, English otherwise.
+// The abbreviation column is padded by rune count (not bytes) so diacritics
+// like "Łk"/"Kpł" still line up.
+func runList(en bool, stdout io.Writer) int {
+ const col = 6
+ for _, b := range bible.Books() {
+ name := b.Polish
+ if en {
+ name = b.English
+ }
+ pad := col - len([]rune(b.Abbrev))
+ if pad < 1 {
+ pad = 1
+ }
+ fmt.Fprintf(stdout, "%s%s%s\n", b.Abbrev, strings.Repeat(" ", pad), name)
+ }
+ return 0
+}
+
// resolveWidth applies the --width 0 = detect-terminal rule: an explicit
// positive width wins, then a real terminal width if stdout is a tty, then
// a sane per-mode default.
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 6fc5e0e..c76d61c 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -267,3 +267,65 @@ func TestExtractDateDefaultsToday(t *testing.T) {
t.Errorf("extractDate rest = %v, want [-a -r]", rest)
}
}
+
+func TestListEN(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"--list-en"}, nil, &out, &errb); code != 0 {
+ t.Fatalf("--list-en code=%d stderr=%q", code, errb.String())
+ }
+ s := out.String()
+ if !strings.Contains(s, "John") || !strings.Contains(s, "Genesis") {
+ t.Errorf("--list-en missing expected books:\n%s", s)
+ }
+ // abbrev-then-name, one book per line; expect a J -> John line.
+ if !strings.Contains(s, "J") {
+ t.Errorf("--list-en missing John abbrev")
+ }
+}
+
+func TestListPL(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"--list-pl"}, nil, &out, &errb); code != 0 {
+ t.Fatalf("--list-pl code=%d stderr=%q", code, errb.String())
+ }
+ if s := out.String(); !strings.Contains(s, "Jan") || !strings.Contains(s, "Rodzaju") {
+ t.Errorf("--list-pl missing Polish names:\n%s", s)
+ }
+}
+
+func TestRefSingle(t *testing.T) {
+ var out, errb bytes.Buffer
+ code := Run([]string{"-p", "J 3:16", "-b", "vul"}, nil, &out, &errb)
+ if code != 0 {
+ t.Fatalf("ref code=%d stderr=%q", code, errb.String())
+ }
+ if s := out.String(); !strings.Contains(s, "3:16") {
+ t.Errorf("ref output missing verse 3:16:\n%s", s)
+ }
+}
+
+func TestRefRejectsBT(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"-p", "J 3:16", "-b", "bt"}, nil, &out, &errb); code != 2 {
+ t.Errorf("ref -b bt code=%d want 2 (stderr=%q)", code, errb.String())
+ }
+}
+
+func TestRefCompare(t *testing.T) {
+ var out, errb bytes.Buffer
+ code := Run([]string{"-p", "J 3:16", "-c", "vul,drb"}, nil, &out, &errb)
+ if code != 0 {
+ t.Fatalf("ref compare code=%d stderr=%q", code, errb.String())
+ }
+ if s := out.String(); !strings.Contains(s, "3:16") {
+ t.Errorf("ref compare missing verse:\n%s", s)
+ }
+}
+
+func TestRefNotFound(t *testing.T) {
+ var out, errb bytes.Buffer
+ // A book/verse the corpus won't have text for -> exit 1.
+ if code := Run([]string{"-p", "Zzz 9:9", "-b", "vul"}, nil, &out, &errb); code != 1 {
+ t.Errorf("ref not-found code=%d want 1 (stderr=%q)", code, errb.String())
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 69d7d2b..616af97 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.5.0"
+const Version = "0.6.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{