From e50b003d84b091b60c8028468edc633f04e39731 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 10:47:48 +0200 Subject: bible-lookup: add --ref passage lookup + --list-pl/--list-en book lists; v0.6.0 --- internal/cli/cli.go | 178 +++++++++++++++++++++++++++++++++++++++++++---- internal/cli/cli_test.go | 62 +++++++++++++++++ 2 files changed, 228 insertions(+), 12 deletions(-) (limited to 'internal/cli') 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 " ", 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()) + } +} -- cgit v1.3