aboutsummaryrefslogtreecommitdiff
path: root/internal/bible/booktable.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/bible/booktable.go')
-rw-r--r--internal/bible/booktable.go163
1 files changed, 163 insertions, 0 deletions
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
+}