diff options
Diffstat (limited to 'internal/bible/booktable.go')
| -rw-r--r-- | internal/bible/booktable.go | 163 |
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 +} |
