aboutsummaryrefslogtreecommitdiff
path: root/internal/bible/booktable.go
blob: de8995c059bf99f26a07c6bcbe1ce1e9f437541a (plain) (blame)
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
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), " "))
	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: "<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
}