summaryrefslogtreecommitdiff
path: root/internal/bible
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 11:29:57 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 11:29:57 +0200
commita26fed000ad9f292618f9cecfd2b505dddfd004a (patch)
tree3b6001cc5a20c3451677773159553aa134d4a6c9 /internal/bible
parente50b003d84b091b60c8028468edc633f04e39731 (diff)
downloadlectio-a26fed000ad9f292618f9cecfd2b505dddfd004a.tar.gz
lectio-a26fed000ad9f292618f9cecfd2b505dddfd004a.zip
books: language-scoped --ref/--list via customizable books.toml + sigla_style config; v0.7.0
Diffstat (limited to 'internal/bible')
-rw-r--r--internal/bible/books.go19
-rw-r--r--internal/bible/books.toml167
-rw-r--r--internal/bible/books_test.go14
-rw-r--r--internal/bible/booktable.go163
-rw-r--r--internal/bible/booktable_test.go88
5 files changed, 418 insertions, 33 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)
+ }
+}