diff options
Diffstat (limited to 'internal/bible')
| -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 |
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) + } +} |
