diff options
| -rw-r--r-- | internal/bible/books.go | 45 | ||||
| -rw-r--r-- | internal/bible/books_test.go | 40 | ||||
| -rw-r--r-- | internal/cli/cli.go | 178 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 62 | ||||
| -rw-r--r-- | internal/config/config.go | 2 |
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{ |
