1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
|
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() }
// CanonicalBooks returns the 73 canonical English book keys -- the exact
// vocabulary a corpus .tsv's Book column must use -- read from the embedded
// books.ini [en] section. It reuses the existing embedded parse (parseBooks
// + mustReadEmbedded); the book list is never re-embedded or hardcoded here.
func CanonicalBooks() map[string]bool {
out := map[string]bool{}
def, err := parseBooks(mustReadEmbedded())
if err != nil {
return out // our bug, not the caller's; an empty set flags everything
}
for canon := range def["en"] {
out[canon] = true
}
return out
}
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
}
if cm := crossChapRangeRe.FindStringSubmatch(g); cm != nil {
// "M-P:Q": a range crossing a chapter boundary. Keep it intact --
// it already reads naturally as "<chap>:M-P:Q" -- rather than
// peeling a fake newChap off the "P:" embedded inside it.
switch {
case i == 0:
b.WriteString(chapVerse(dialect, chap, g))
case dialect == "pl":
b.WriteString(". " + g)
default:
b.WriteString("," + g)
}
chap = cm[2] // later groups (if any) belong to chapter P
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: "<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)
}
// 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
}
|