package bible import ( "embed" "fmt" "sort" "strings" "github.com/lukaszkasprzak/lectio/internal/ini" ) // booksFS embeds the built-in default book table (names + abbreviations per // dialect: en/pl/la). Users override it per book via ~/.config/lectio/books.ini. // //go:embed books.ini 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.ini // section, keyed by dialect ("en"/"pl"/"la"). 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(userINI []byte) (*BookTable, error) { def, err := parseBooks(mustReadEmbedded()) if err != nil { return nil, fmt.Errorf("embedded books.ini: %w", err) // our bug, not the user's } var uerr error if len(strings.TrimSpace(string(userINI))) > 0 { user, perr := parseBooks(userINI) if perr != nil { uerr = fmt.Errorf("ignoring malformed books.ini: %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.ini") // embedded: always present return b } // DefaultBooksTOML returns the embedded default books.ini (the seed the // /settings editor shows when the user has no override yet). func DefaultBooksINI() []byte { return mustReadEmbedded() } func parseBooks(data []byte) (map[string]map[string][]string, error) { secs, err := ini.Parse(data) if err != nil { return nil, err } out := map[string]map[string][]string{} for _, s := range secs { if s.Name == "" { // no top-level pairs in the book table continue } if out[s.Name] == nil { out[s.Name] = map[string][]string{} } for _, p := range s.Pairs { out[s.Name][p.Key] = ini.List(p.Val) } } return out, 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), " ")) low = bookDotRe.ReplaceAllString(low, "$1") // drop abbreviation dots ("1 cor. 9" -> "1 cor 9") 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 } // shortcut returns the dialect's primary abbreviation (the first form) for a // canonical book, or "" when the dialect has no entry for it. func (t *BookTable) shortcut(dialect, canonical string) string { if forms := t.forms[dialect][canonical]; len(forms) > 0 { return forms[0] } return "" } // Abbrev returns the dialect's primary abbreviation for a canonical book, // falling back to the canonical name when the dialect has no entry. func (t *BookTable) Abbrev(dialect, canonical string) string { if a := t.shortcut(dialect, canonical); a != "" { return a } return canonical } // FormatRef renders a canonical English reference ("John 20:1,11-18") in the // dialect's sigla: the dialect's book shortcut plus its number style -- English // "Jn 20:1,11-18" (colon chapter:verse, comma groups); Polish "J 20, 1. 11-18" // (comma chapter, verse; period groups). It is the inverse of ParseRef's // normalizeSigla. If canonicalRef can't be parsed or the book is not in the // dialect, it is returned unchanged (a safe, still-readable fallback). func (t *BookTable) FormatRef(dialect, canonicalRef string) string { m := refRe.FindStringSubmatch(strings.TrimSpace(canonicalRef)) if m == nil { return canonicalRef } canon, chap, rest := m[1], m[2], m[3] abbrev := t.shortcut(dialect, canon) if abbrev == "" { return canonicalRef } // rest is a comma-separated group list; a group with ":" opens a new chapter. var b strings.Builder b.WriteString(abbrev + " ") for i, g := range strings.Split(rest, ",") { g = strings.TrimSpace(g) if g == "" { continue } newChap := "" if idx := strings.IndexByte(g, ':'); idx >= 0 { newChap, g = g[:idx], g[idx+1:] } switch { case i == 0: // first group uses the chapter from refRe b.WriteString(chapVerse(dialect, chap, g)) case newChap != "" && newChap != chap: // cross-chapter group chap = newChap b.WriteString("; " + chapVerse(dialect, chap, g)) case dialect == "pl": // another verse group in the same chapter b.WriteString(". " + g) default: b.WriteString("," + g) } } return b.String() } // chapVerse renders one "chapter + verses" in the dialect's style: English // "5:15-17", Polish "5, 15-17". func chapVerse(dialect, chap, verses string) string { if dialect == "pl" { return chap + ", " + verses } return chap + ":" + verses } // normalizeSigla rewrites a dialect's verse-reference tail into the colon/comma // form bible.Lookup expects: ":", 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) } // A semicolon separates cross-chapter groups ("13:22-25; 15:10"); treat it // as a group separator so bible.SplitRef resolves each chapter. tail = strings.ReplaceAll(tail, ";", ",") tail = dashRe.ReplaceAllString(tail, "-") tail = verseLetterRe.ReplaceAllString(tail, "$1") tail = strings.ReplaceAll(tail, " ", "") return tail }