diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 11:29:57 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-24 11:29:57 +0200 |
| commit | a26fed000ad9f292618f9cecfd2b505dddfd004a (patch) | |
| tree | 3b6001cc5a20c3451677773159553aa134d4a6c9 | |
| parent | e50b003d84b091b60c8028468edc633f04e39731 (diff) | |
| download | lectio-a26fed000ad9f292618f9cecfd2b505dddfd004a.tar.gz lectio-a26fed000ad9f292618f9cecfd2b505dddfd004a.zip | |
books: language-scoped --ref/--list via customizable books.toml + sigla_style config; v0.7.0
| -rw-r--r-- | internal/bible/books.go | 19 | ||||
| -rw-r--r-- | internal/bible/books.toml | 167 | ||||
| -rw-r--r-- | internal/bible/books_test.go | 14 | ||||
| -rw-r--r-- | internal/bible/booktable.go | 163 | ||||
| -rw-r--r-- | internal/bible/booktable_test.go | 88 | ||||
| -rw-r--r-- | internal/cli/cli.go | 90 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 87 | ||||
| -rw-r--r-- | internal/config/config.go | 47 | ||||
| -rw-r--r-- | internal/config/config.toml | 1 | ||||
| -rw-r--r-- | internal/config/config_test.go | 21 |
10 files changed, 595 insertions, 102 deletions
diff --git a/internal/bible/books.go b/internal/bible/books.go index f3af4af..037a5c3 100644 --- a/internal/bible/books.go +++ b/internal/bible/books.go @@ -138,22 +138,3 @@ var canonOrder = []string{ "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.toml b/internal/bible/books.toml new file mode 100644 index 0000000..a9305d1 --- /dev/null +++ b/internal/bible/books.toml @@ -0,0 +1,167 @@ +# lectio book names & abbreviations, per UI language. +# +# This is the built-in default, embedded in the binary. To customise, copy any +# lines you want to change into ~/.config/lectio/books.toml (next to config.toml). +# Your file is MERGED over these defaults per book: you only list the books you +# want to change; every book you don't mention keeps the default below. +# +# Each entry is a flat list of forms for one book, in ONE language: +# - the FIRST form is the shortcut shown by `lectio --list` +# - the LAST form is the full name shown by `lectio --list` +# - EVERY form (first, last, and any in between) resolves in `lectio --ref` +# Resolution is language-scoped and idiomatic: in English mode only the [en] +# forms resolve (so "Jn" works, "Łk" does not); in Polish mode only [pl]. +# +# The table KEY is the book's English canonical name -- it must match the Bible +# corpus, so do not change the keys, only the forms. Books appear in `--list` +# in scriptural order regardless of the order here. + +[en] +Genesis = ["Gen", "Genesis"] +Exodus = ["Exod", "Exodus"] +Leviticus = ["Lev", "Leviticus"] +Numbers = ["Num", "Numbers"] +Deuteronomy = ["Deut", "Deuteronomy"] +Joshua = ["Josh", "Joshua"] +Judges = ["Judg", "Judges"] +Ruth = ["Ru", "Ruth"] +"1 Samuel" = ["1 Sam", "1 Samuel"] +"2 Samuel" = ["2 Sam", "2 Samuel"] +"1 Kings" = ["1 Kgs", "1 Kings"] +"2 Kings" = ["2 Kgs", "2 Kings"] +"1 Chronicles" = ["1 Chr", "1 Chronicles"] +"2 Chronicles" = ["2 Chr", "2 Chronicles"] +Ezra = ["Ezra", "Ezra"] +Nehemiah = ["Neh", "Nehemiah"] +Tobit = ["Tob", "Tobit"] +Judith = ["Jdt", "Judith"] +Esther = ["Esth", "Esther"] +"1 Maccabees" = ["1 Macc", "1 Maccabees"] +"2 Maccabees" = ["2 Macc", "2 Maccabees"] +Job = ["Jb", "Job"] +Psalms = ["Ps", "Psalm", "Psalms"] +Proverbs = ["Prov", "Proverbs"] +Ecclesiastes = ["Eccl", "Ecclesiastes"] +"Song of Solomon" = ["Song", "Song of Songs"] +Wisdom = ["Wis", "Wisdom"] +Sirach = ["Sir", "Sirach"] +Isaiah = ["Isa", "Isaiah"] +Jeremiah = ["Jer", "Jeremiah"] +Lamentations = ["Lam", "Lamentations"] +Baruch = ["Bar", "Baruch"] +Ezekiel = ["Ezek", "Ezekiel"] +Daniel = ["Dan", "Daniel"] +Hosea = ["Hos", "Hosea"] +Joel = ["Jl", "Joel"] +Amos = ["Am", "Amos"] +Obadiah = ["Obad", "Obadiah"] +Jonah = ["Jon", "Jonah"] +Micah = ["Mic", "Micah"] +Nahum = ["Nah", "Nahum"] +Habakkuk = ["Hab", "Habakkuk"] +Zephaniah = ["Zeph", "Zephaniah"] +Haggai = ["Hag", "Haggai"] +Zechariah = ["Zech", "Zechariah"] +Malachi = ["Mal", "Malachi"] +Matthew = ["Matt", "Matthew"] +Mark = ["Mk", "Mark"] +Luke = ["Lk", "Luke"] +John = ["Jn", "John"] +"The Acts" = ["Acts", "Acts"] +Romans = ["Rom", "Romans"] +"1 Corinthians" = ["1 Cor", "1 Corinthians"] +"2 Corinthians" = ["2 Cor", "2 Corinthians"] +Galatians = ["Gal", "Galatians"] +Ephesians = ["Eph", "Ephesians"] +Philippians = ["Phil", "Philippians"] +Colossians = ["Col", "Colossians"] +"1 Thessalonians" = ["1 Thess", "1 Thessalonians"] +"2 Thessalonians" = ["2 Thess", "2 Thessalonians"] +"1 Timothy" = ["1 Tim", "1 Timothy"] +"2 Timothy" = ["2 Tim", "2 Timothy"] +Titus = ["Ti", "Titus"] +Philemon = ["Phlm", "Philemon"] +Hebrews = ["Heb", "Hebrews"] +James = ["Jas", "James"] +"1 Peter" = ["1 Pet", "1 Peter"] +"2 Peter" = ["2 Pet", "2 Peter"] +"1 John" = ["1 Jn", "1 John"] +"2 John" = ["2 Jn", "2 John"] +"3 John" = ["3 Jn", "3 John"] +Jude = ["Jude", "Jude"] +Revelation = ["Rev", "Revelation"] + +[pl] +Genesis = ["Rdz", "Rodzaju"] +Exodus = ["Wj", "Wyjścia"] +Leviticus = ["Kpł", "Kapłańska"] +Numbers = ["Lb", "Liczb"] +Deuteronomy = ["Pwt", "Powtórzonego Prawa"] +Joshua = ["Joz", "Jozuego"] +Judges = ["Sdz", "Sędziów"] +Ruth = ["Rt", "Rut"] +"1 Samuel" = ["1 Sm", "1 Samuela"] +"2 Samuel" = ["2 Sm", "2 Samuela"] +"1 Kings" = ["1 Krl", "1 Królewska"] +"2 Kings" = ["2 Krl", "2 Królewska"] +"1 Chronicles" = ["1 Krn", "1 Kronik"] +"2 Chronicles" = ["2 Krn", "2 Kronik"] +Ezra = ["Ezd", "Ezdrasza"] +Nehemiah = ["Ne", "Nehemiasza"] +Tobit = ["Tb", "Tobiasza"] +Judith = ["Jdt", "Judyty"] +Esther = ["Est", "Estery"] +"1 Maccabees" = ["1 Mch", "1 Machabejska"] +"2 Maccabees" = ["2 Mch", "2 Machabejska"] +Job = ["Hi", "Hioba"] +Psalms = ["Ps", "Psalm", "Psalmy"] +Proverbs = ["Prz", "Przysłów"] +Ecclesiastes = ["Koh", "Koheleta"] +"Song of Solomon" = ["Pnp", "Pieśń nad Pieśniami"] +Wisdom = ["Mdr", "Mądrości"] +Sirach = ["Syr", "Syracha"] +Isaiah = ["Iz", "Izajasza"] +Jeremiah = ["Jr", "Jeremiasza"] +Lamentations = ["Lm", "Lamentacje"] +Baruch = ["Ba", "Barucha"] +Ezekiel = ["Ez", "Ezechiela"] +Daniel = ["Dn", "Daniela"] +Hosea = ["Oz", "Ozeasza"] +Joel = ["Jl", "Joela"] +Amos = ["Am", "Amosa"] +Obadiah = ["Ab", "Abdiasza"] +Jonah = ["Jon", "Jonasza"] +Micah = ["Mi", "Micheasza"] +Nahum = ["Na", "Nahuma"] +Habakkuk = ["Ha", "Habakuka"] +Zephaniah = ["So", "Sofoniasza"] +Haggai = ["Ag", "Aggeusza"] +Zechariah = ["Za", "Zachariasza"] +Malachi = ["Ml", "Malachiasza"] +Matthew = ["Mt", "Mateusza"] +Mark = ["Mk", "Marka"] +Luke = ["Łk", "Łukasza"] +John = ["J", "Jana"] +"The Acts" = ["Dz", "Dzieje Apostolskie"] +Romans = ["Rz", "Rzymian"] +"1 Corinthians" = ["1 Kor", "1 Koryntian"] +"2 Corinthians" = ["2 Kor", "2 Koryntian"] +Galatians = ["Ga", "Galatów"] +Ephesians = ["Ef", "Efezjan"] +Philippians = ["Flp", "Filipian"] +Colossians = ["Kol", "Kolosan"] +"1 Thessalonians" = ["1 Tes", "1 Tesaloniczan"] +"2 Thessalonians" = ["2 Tes", "2 Tesaloniczan"] +"1 Timothy" = ["1 Tm", "1 Tymoteusza"] +"2 Timothy" = ["2 Tm", "2 Tymoteusza"] +Titus = ["Tt", "Tytusa"] +Philemon = ["Flm", "Filemona"] +Hebrews = ["Hbr", "Hebrajczyków"] +James = ["Jk", "Jakuba"] +"1 Peter" = ["1 P", "1 Piotra"] +"2 Peter" = ["2 P", "2 Piotra"] +"1 John" = ["1 J", "1 Jana"] +"2 John" = ["2 J", "2 Jana"] +"3 John" = ["3 J", "3 Jana"] +Jude = ["Jud", "Judy"] +Revelation = ["Ap", "Apokalipsa"] diff --git a/internal/bible/books_test.go b/internal/bible/books_test.go index e1a65ca..e89cf6e 100644 --- a/internal/bible/books_test.go +++ b/internal/bible/books_test.go @@ -50,17 +50,3 @@ func TestBooksTableIntegrity(t *testing.T) { } } } - -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/bible/booktable.go b/internal/bible/booktable.go new file mode 100644 index 0000000..eb3b4e4 --- /dev/null +++ b/internal/bible/booktable.go @@ -0,0 +1,163 @@ +package bible + +import ( + "embed" + "fmt" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +// booksFS embeds the built-in default book table (names + abbreviations per UI +// language). Users override it per book via ~/.config/lectio/books.toml. +// +//go:embed books.toml +var booksFS embed.FS + +// BookInfo is one book's display data in one dialect, for `lectio --list` and +// (later) the reader book pickers. +type BookInfo struct { + Canonical string // English canonical name (the Bible-corpus key), e.g. "John" + Name string // full display name in the dialect, e.g. "John" / "Jana" + Shortcut string // primary abbreviation in the dialect, e.g. "Jn" / "J" +} + +// BookTable resolves book references and lists books per "dialect" -- a books.toml +// section, keyed by the same codes as the UI languages ("en"/"pl"). It is built +// from the embedded default merged with an optional user override. +type BookTable struct { + forms map[string]map[string][]string // dialect -> canonical -> forms (first=shortcut, last=name) + resolve map[string]map[string]string // dialect -> lowercased form -> canonical + sorted map[string][]string // dialect -> lowercased forms, longest first (book matching) +} + +// LoadBookTable builds the table from the embedded default, merging userTOML +// over it per book (a book the user lists replaces that book's default forms; +// unlisted books keep the default). userTOML may be nil/empty (defaults only). +// It always returns a usable table; a non-nil error means userTOML was present +// but unparseable (the returned table is defaults-only, so callers can warn and +// proceed). +func LoadBookTable(userTOML []byte) (*BookTable, error) { + def, err := parseBooks(mustReadEmbedded()) + if err != nil { + return nil, fmt.Errorf("embedded books.toml: %w", err) // our bug, not the user's + } + var uerr error + if len(strings.TrimSpace(string(userTOML))) > 0 { + user, perr := parseBooks(userTOML) + if perr != nil { + uerr = fmt.Errorf("ignoring malformed books.toml: %w", perr) + } else { + for dialect, books := range user { + if def[dialect] == nil { + def[dialect] = map[string][]string{} + } + for canon, forms := range books { + def[dialect][canon] = forms + } + } + } + } + t := &BookTable{ + forms: def, + resolve: map[string]map[string]string{}, + sorted: map[string][]string{}, + } + for dialect, books := range def { + t.resolve[dialect] = map[string]string{} + var forms []string + for canon, fs := range books { + for _, f := range fs { + k := strings.ToLower(strings.TrimSpace(f)) + if k == "" { + continue + } + t.resolve[dialect][k] = canon + forms = append(forms, k) + } + } + sort.SliceStable(forms, func(i, j int) bool { return len(forms[i]) > len(forms[j]) }) + t.sorted[dialect] = forms + } + return t, uerr +} + +func mustReadEmbedded() []byte { + b, _ := booksFS.ReadFile("books.toml") // embedded: always present + return b +} + +func parseBooks(data []byte) (map[string]map[string][]string, error) { + var raw map[string]map[string][]string + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, err + } + return raw, nil +} + +// Books returns every book of the dialect in scriptural (canonOrder) order. +// A dialect with no entry for a canonical book simply omits it. +func (t *BookTable) Books(dialect string) []BookInfo { + books := t.forms[dialect] + out := make([]BookInfo, 0, len(canonOrder)) + for _, canon := range canonOrder { + forms := books[canon] + if len(forms) == 0 { + continue + } + out = append(out, BookInfo{ + Canonical: canon, + Shortcut: forms[0], + Name: forms[len(forms)-1], + }) + } + return out +} + +// ParseRef parses a user-typed reference in the dialect into the English, +// colon-style reference bible.Lookup expects ("John 5:15-17,20-22"), or +// ok=false if the book is not recognised in this dialect or there is no +// chapter:verse tail. Book resolution is dialect-scoped (idiomatic): only the +// dialect's own forms match. The number syntax is dialect-specific -- see +// normalizeSigla. +func (t *BookTable) ParseRef(dialect, input string) (string, bool) { + low := strings.ToLower(strings.Join(strings.Fields(input), " ")) + if low == "" { + return "", false + } + for _, form := range t.sorted[dialect] { + if low == form { + return "", false // book only, no chapter:verse + } + if strings.HasPrefix(low, form+" ") { + tail := normalizeSigla(dialect, low[len(form)+1:]) + if tail == "" || !strings.Contains(tail, ":") { + return "", false + } + return t.resolve[dialect][form] + " " + tail, true + } + } + return "", false +} + +// normalizeSigla rewrites a dialect's verse-reference tail into the colon/comma +// form bible.Lookup expects: "<chap>:<groups>", groups comma-separated, ranges +// with "-". Polish uses a comma (with or without a space) as the chapter/verse +// separator and a period between disjoint groups (and " i " for "and"); +// English uses a colon for chapter/verse and a period or comma between groups. +// Reuses the shared regexes iRe/dashRe/verseLetterRe from convert.go. +func normalizeSigla(dialect, tail string) string { + tail = strings.TrimSpace(tail) + if dialect == "pl" { + tail = strings.ReplaceAll(tail, ",", ":") // chapter,verse -> chapter:verse + tail = iRe.ReplaceAllString(tail, ",") // Polish 'and' + tail = strings.ReplaceAll(tail, ".", ",") // disjoint groups + } else { + tail = strings.ReplaceAll(tail, ".", ",") // disjoint groups (comma already fine) + } + tail = dashRe.ReplaceAllString(tail, "-") + tail = verseLetterRe.ReplaceAllString(tail, "$1") + tail = strings.ReplaceAll(tail, " ", "") + return tail +} diff --git a/internal/bible/booktable_test.go b/internal/bible/booktable_test.go new file mode 100644 index 0000000..ededad3 --- /dev/null +++ b/internal/bible/booktable_test.go @@ -0,0 +1,88 @@ +package bible + +import ( + "testing" +) + +func TestBookTableDefaults(t *testing.T) { + tbl, err := LoadBookTable(nil) + if err != nil { + t.Fatal(err) + } + for _, d := range []string{"en", "pl"} { + if got := len(tbl.Books(d)); got != 73 { + t.Errorf("Books(%q) len=%d want 73", d, got) + } + } + en := tbl.Books("en") + if en[0].Canonical != "Genesis" || en[len(en)-1].Canonical != "Revelation" { + t.Errorf("en order: first=%q last=%q", en[0].Canonical, en[len(en)-1].Canonical) + } + // John display: en shortcut "Jn"/name "John"; pl shortcut "J"/name "Jana". + find := func(bs []BookInfo, canon string) BookInfo { + for _, b := range bs { + if b.Canonical == canon { + return b + } + } + return BookInfo{} + } + if b := find(en, "John"); b.Shortcut != "Jn" || b.Name != "John" { + t.Errorf("en John = %+v", b) + } + if b := find(tbl.Books("pl"), "John"); b.Shortcut != "J" || b.Name != "Jana" { + t.Errorf("pl John = %+v", b) + } +} + +func TestParseRefDialects(t *testing.T) { + tbl, _ := LoadBookTable(nil) + cases := []struct { + dialect, in, want string + ok bool + }{ + {"en", "Jn 3:16", "John 3:16", true}, + {"en", "Jn 5:15-17.20-22", "John 5:15-17,20-22", true}, + {"en", "1 Cor 13:4-7", "1 Corinthians 13:4-7", true}, + {"pl", "J 3,16", "John 3:16", true}, + {"pl", "J 3, 16", "John 3:16", true}, + {"pl", "J 6,6", "John 6:6", true}, + {"pl", "J 5,15-17. 20-22", "John 5:15-17,20-22", true}, + {"pl", "1 Kor 13,4-7", "1 Corinthians 13:4-7", true}, + {"pl", "Łk 1,46-55", "Luke 1:46-55", true}, + // dialect scoping: Polish abbrev/sep must fail in en, English in pl. + {"en", "Łk 3:16", "", false}, + {"pl", "Lk 3,16", "", false}, + {"en", "Zzz 1:1", "", false}, + {"en", "John", "", false}, // no chapter:verse + } + for _, c := range cases { + got, ok := tbl.ParseRef(c.dialect, c.in) + if ok != c.ok || got != c.want { + t.Errorf("ParseRef(%q,%q)=%q,%v want %q,%v", c.dialect, c.in, got, ok, c.want, c.ok) + } + } +} + +func TestBookTableMergeAndMalformed(t *testing.T) { + // User adds an alias "Jhn" to English John; other books stay default. + user := []byte("[en]\nJohn = [\"Jn\", \"Jhn\", \"John\"]\n") + tbl, err := LoadBookTable(user) + if err != nil { + t.Fatalf("merge err: %v", err) + } + if got, ok := tbl.ParseRef("en", "Jhn 3:16"); !ok || got != "John 3:16" { + t.Errorf("merged alias: got %q,%v", got, ok) + } + if got, ok := tbl.ParseRef("en", "Gen 1:1"); !ok || got != "Genesis 1:1" { + t.Errorf("unlisted book lost after merge: %q,%v", got, ok) + } + // Malformed user TOML -> non-nil error but a usable defaults table. + tbl2, err := LoadBookTable([]byte("this is not [valid toml")) + if err == nil { + t.Error("expected error for malformed user toml") + } + if got, ok := tbl2.ParseRef("en", "Jn 3:16"); !ok || got != "John 3:16" { + t.Errorf("defaults unusable after malformed user toml: %q,%v", got, ok) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bd080b9..76ebd5b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -42,8 +42,7 @@ 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 + --list list all books + abbreviations (dialect from sigla_style) -v, --version print the version and exit -h, --help this help @@ -57,9 +56,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 + 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 Flags override config. Exit codes: 0 ok, 1 runtime error (fetch/parse), 2 usage error (bad flag, bad date, bad version, bad --lectionary/--lang). @@ -100,7 +99,7 @@ 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 list bool var ref string fs := flag.NewFlagSet("lectio", flag.ContinueOnError) @@ -134,8 +133,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { 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") + fs.BoolVar(&list, "list", false, "list all books + abbreviations (in your sigla_style)") if err := fs.Parse(rest); err != nil { return 2 @@ -217,6 +215,15 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } } + var bookTbl *bible.BookTable + if list || ref != "" { + tbl, terr := bible.LoadBookTable(userBooksTOML()) + if terr != nil { + fmt.Fprintln(stderr, "lectio:", terr) // warn; tbl is still a usable defaults table + } + bookTbl = tbl + } + out := stdout finish := func() {} if pagerRequested(pagerFlag, noPager, cfg) && isTerminalWriter(stdout) { @@ -228,10 +235,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 list: + code = runList(bookTbl, cfg.SiglaLang(), out) case ref != "": - code = lookupRef(cfg, ref, refVersions, raw, effWidth, out, stderr) + code = lookupRef(cfg, bookTbl, ref, refVersions, raw, effWidth, out, stderr) case compareList != "": code = renderCompare(cfg, compareList, date, effAll, raw, effWidth, refresh, out, stderr) case bibleVer != "": @@ -243,6 +250,20 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return code } +// 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 { + p, err := config.BooksPath() + if err != nil { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + return b +} + // wantsHelp reports whether -h/--help appears anywhere in args. func wantsHelp(args []string) bool { for _, a := range args { @@ -603,24 +624,26 @@ func refLookupVersions(cfg config.Config, bibleVer, compareList string) ([]strin 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 { +// lookupRef renders a passage lookup (-p/--ref). It parses the typed reference +// in the resolved sigla dialect (bookTbl.ParseRef -- dialect-scoped book names +// and number syntax) into an English colon-style reference, then reuses the +// same render path as the readings with lectionary="traditional" (the ref is +// already in target form, looked up literally per version -- no niedziela +// conversion, no cross-version psalm renumbering). versions is the validated +// corpus set (never "bt"). raw omits the header. Exit 2 on an unparseable ref, +// exit 1 if no requested version has the passage. +func lookupRef(cfg config.Config, tbl *bible.BookTable, 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") + engRef, ok := tbl.ParseRef(cfg.SiglaLang(), ref) + if !ok { + fmt.Fprintf(stderr, "lectio: could not read reference %q in the %s dialect; see 'lectio --list'\n", ref, cfg.SiglaLang()) return 2 } - sec := liturgy.Section{Citation: ref, Heading: ref} + sec := liturgy.Section{Citation: engRef, Heading: engRef} found := false for _, v := range versions { - if vs, _ := bible.Lookup(v, ref); len(vs) > 0 { + if vs, _ := bible.Lookup(v, engRef); len(vs) > 0 { found = true break } @@ -663,22 +686,17 @@ func lookupRef(cfg config.Config, ref string, versions []string, raw bool, width 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)) +// runList handles --list: print every book of the sigla dialect (scriptural +// order) as "<shortcut> <name>". The shortcut column is padded by rune count +// so diacritics ("Łk") still line up. +func runList(tbl *bible.BookTable, dialect string, stdout io.Writer) int { + const col = 8 + for _, b := range tbl.Books(dialect) { + pad := col - len([]rune(b.Shortcut)) if pad < 1 { pad = 1 } - fmt.Fprintf(stdout, "%s%s%s\n", b.Abbrev, strings.Repeat(" ", pad), name) + fmt.Fprintf(stdout, "%s%s%s\n", b.Shortcut, strings.Repeat(" ", pad), b.Name) } return 0 } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c76d61c..bc7dfdb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/lukaszkasprzak/lectio/internal/bible" "github.com/lukaszkasprzak/lectio/internal/liturgy" ) @@ -268,64 +269,86 @@ func TestExtractDateDefaultsToday(t *testing.T) { } } -func TestListEN(t *testing.T) { +func TestRefSingle(t *testing.T) { + var out, errb bytes.Buffer + code := Run([]string{"-p", "Jn 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 TestRefNotFound(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()) + // An unknown book -> ParseRef fails to resolve it in the dialect -> exit 2. + if code := Run([]string{"-p", "Zzz 9:9", "-b", "vul"}, nil, &out, &errb); code != 2 { + t.Errorf("ref unknown-book code=%d want 2 (stderr=%q)", code, errb.String()) + } +} + +func TestRunListEnglish(t *testing.T) { + tbl, _ := bible.LoadBookTable(nil) + var out bytes.Buffer + if code := runList(tbl, "en", &out); code != 0 { + t.Fatalf("runList en code=%d", code) } - s := out.String() - if !strings.Contains(s, "John") || !strings.Contains(s, "Genesis") { - t.Errorf("--list-en missing expected books:\n%s", s) + if s := out.String(); !strings.Contains(s, "Jn") || !strings.Contains(s, "John") { + t.Errorf("en list missing Jn/John:\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 TestRunListPolish(t *testing.T) { + tbl, _ := bible.LoadBookTable(nil) + var out bytes.Buffer + runList(tbl, "pl", &out) + if s := out.String(); !strings.Contains(s, "Jana") || !strings.Contains(s, "Rodzaju") { + t.Errorf("pl list missing Jana/Rodzaju:\n%s", s) } } -func TestListPL(t *testing.T) { +func TestListFlag(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 code := Run([]string{"--list"}, nil, &out, &errb); code != 0 { + t.Fatalf("--list 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) + if n := strings.Count(strings.TrimSpace(out.String()), "\n") + 1; n < 73 { + t.Errorf("--list printed %d lines, want >=73", n) } } -func TestRefSingle(t *testing.T) { +func TestRefEnglishDialect(t *testing.T) { var out, errb bytes.Buffer - code := Run([]string{"-p", "J 3:16", "-b", "vul"}, nil, &out, &errb) - if code != 0 { + if code := Run([]string{"-p", "Jn 3:16", "-b", "vul"}, nil, &out, &errb); 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) + if !strings.Contains(out.String(), "3:16") { + t.Errorf("ref output missing 3:16:\n%s", out.String()) + } +} + +func TestRefRejectsPolishAbbrevInEnglish(t *testing.T) { + var out, errb bytes.Buffer + // English dialect (default): the Polish abbrev "Łk" must not resolve. + if code := Run([]string{"-p", "Łk 3:16", "-b", "vul"}, nil, &out, &errb); code != 2 { + t.Errorf("Łk in en dialect code=%d want 2 (stderr=%q)", code, errb.String()) } } 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 { + if code := Run([]string{"-p", "Jn 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 { + if code := Run([]string{"-p", "Jn 3:16", "-c", "vul,drb"}, nil, &out, &errb); 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()) + if !strings.Contains(out.String(), "3:16") { + t.Errorf("ref compare missing verse:\n%s", out.String()) } } diff --git a/internal/config/config.go b/internal/config/config.go index 616af97..8ee15e1 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.6.0" +const Version = "0.7.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ @@ -92,6 +92,20 @@ func NormalizeUILanguage(lang string) string { return l } +// NormalizeSiglaStyle maps a sigla_style setting to one of "auto", "polish", +// "english"; anything unrecognised (incl. "") falls back to "auto". Accepts the +// short aliases "pl"/"en" for convenience. +func NormalizeSiglaStyle(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "polish", "pl": + return "polish" + case "english", "en": + return "english" + default: + return "auto" + } +} + // Config holds lectio's user-configurable settings. type Config struct { SchemaVersion int `toml:"schema_version"` @@ -103,6 +117,7 @@ type Config struct { All bool `toml:"all"` Offline bool `toml:"offline"` UILanguage string `toml:"ui_language"` + SiglaStyle string `toml:"sigla_style"` WebTheme string `toml:"web_theme"` WebPort int `toml:"web_port"` WebDisplay string `toml:"web_display"` @@ -122,6 +137,23 @@ func (c Config) PartShown(lectionary, partID string) bool { return true } +// SiglaLang resolves the book dialect (a books.toml section code, "en"/"pl") +// that `lectio --ref`/`--list` use: the explicit sigla_style, or -- when "auto" +// -- the UI language. +func (c Config) SiglaLang() string { + switch c.SiglaStyle { + case "polish": + return "pl" + case "english": + return "en" + default: + if c.UILanguage == "pl" { + return "pl" + } + return "en" + } +} + // Default returns lectio's built-in configuration, used when no config file // is found and as the base that a partial config file overrides. func Default() Config { @@ -135,6 +167,7 @@ func Default() Config { All: false, Offline: false, UILanguage: "en", + SiglaStyle: "auto", WebTheme: "transfiguration", WebPort: 0, WebDisplay: "horizontal", @@ -159,6 +192,17 @@ func configPath() (path string, isDefault bool, err error) { return filepath.Join(dir, "lectio", "config.toml"), true, nil } +// BooksPath returns the path to the optional user books.toml (in the same +// directory as the config file). There is no embedded seed on disk -- absence +// means "use the built-in defaults". +func BooksPath() (string, error) { + p, _, err := configPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(p), "books.toml"), nil +} + // seedIfMissing writes the embedded default config to path if nothing is // there yet. func seedIfMissing(path string) error { @@ -208,6 +252,7 @@ func Load() (Config, error) { } cfg.WebDisplay = NormalizeDisplay(cfg.WebDisplay) cfg.UILanguage = NormalizeUILanguage(cfg.UILanguage) + cfg.SiglaStyle = NormalizeSiglaStyle(cfg.SiglaStyle) if err := validate(cfg); err != nil { return Config{}, err diff --git a/internal/config/config.toml b/internal/config/config.toml index 656ddc5..8324032 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -7,6 +7,7 @@ width = 0 # CLI wrap width; 0 = detect terminal all = false # default to all parts (true) or just the gospel (false) offline = false # true = never fetch; read only harvested sigla + cache ui_language = "en" # interface language (labels/keybar/banner): "en" or "pl". Readings stay source-language. +sigla_style = "auto" # citation dialect for `lectio --ref`/`--list`: "auto" (follow ui_language), "polish" (J 3,16), or "english" (Jn 3:16) web_theme = "transfiguration" # default lectio-web theme (built-in order/season name, or a user theme in ~/.config/lectio/themes/) web_port = 0 # lectio-web port; 0 = try 1099, then any free port web_display = "horizontal" # default lectio-web layout ("uklad"): "horizontal" (stacked), "vertical" (columns), "interlinear" (verse-by-verse) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a72383b..cc0c941 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -178,6 +178,27 @@ func TestPagerLoadsSetting(t *testing.T) { } } +func TestSiglaStyleDefaultsAndDialect(t *testing.T) { + if got := Default().SiglaStyle; got != "auto" { + t.Errorf("default SiglaStyle=%q want auto", got) + } + cases := []struct{ style, ui, want string }{ + {"auto", "en", "en"}, + {"auto", "pl", "pl"}, + {"polish", "en", "pl"}, + {"english", "pl", "en"}, + } + for _, c := range cases { + cfg := Config{SiglaStyle: NormalizeSiglaStyle(c.style), UILanguage: c.ui} + if got := cfg.SiglaLang(); got != c.want { + t.Errorf("SiglaLang(style=%q,ui=%q)=%q want %q", c.style, c.ui, got, c.want) + } + } + if NormalizeSiglaStyle("nonsense") != "auto" { + t.Error("unknown sigla_style should normalize to auto") + } +} + func TestPartShown(t *testing.T) { var empty Config if !empty.PartShown("new", "psalm") { |
