rv-bible-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/dist/db.d.ts +63 -0
- package/dist/db.js +153 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.js +258 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +561 -0
- package/dist/parser.d.ts +20 -0
- package/dist/parser.js +320 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.js +30 -0
- package/dist/ui/Pager.d.ts +5 -0
- package/dist/ui/Pager.js +999 -0
- package/dist/ui/nav.d.ts +6 -0
- package/dist/ui/nav.js +37 -0
- package/package.json +60 -0
- package/rv.db +0 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Bible reference parser
|
|
2
|
+
// Converts user input like "john 3:16-18" → structured ParsedRef
|
|
3
|
+
// Maps lowercased user input → canonical DB abbreviation
|
|
4
|
+
const ALIASES = {
|
|
5
|
+
// Genesis
|
|
6
|
+
gen: 'Gen', genesis: 'Gen',
|
|
7
|
+
// Exodus
|
|
8
|
+
exo: 'Exo', ex: 'Exo', exod: 'Exo', exodus: 'Exo',
|
|
9
|
+
// Leviticus
|
|
10
|
+
lev: 'Lev', leviticus: 'Lev',
|
|
11
|
+
// Numbers
|
|
12
|
+
num: 'Num', numb: 'Num', numbers: 'Num',
|
|
13
|
+
// Deuteronomy
|
|
14
|
+
deu: 'Deu', deut: 'Deu', dt: 'Deu', deuteronomy: 'Deu',
|
|
15
|
+
// Joshua
|
|
16
|
+
jos: 'Jos', josh: 'Jos', joshua: 'Jos',
|
|
17
|
+
// Judges
|
|
18
|
+
jdg: 'Jdg', judg: 'Jdg', judges: 'Jdg',
|
|
19
|
+
// Ruth
|
|
20
|
+
rut: 'Rut', ruth: 'Rut',
|
|
21
|
+
// 1 Samuel
|
|
22
|
+
'1sa': '1Sa', '1sam': '1Sa', '1samuel': '1Sa', '1 sam': '1Sa', '1 samuel': '1Sa',
|
|
23
|
+
// 2 Samuel
|
|
24
|
+
'2sa': '2Sa', '2sam': '2Sa', '2samuel': '2Sa', '2 sam': '2Sa', '2 samuel': '2Sa',
|
|
25
|
+
// 1 Kings
|
|
26
|
+
'1ki': '1Ki', '1kgs': '1Ki', '1kings': '1Ki', '1 kings': '1Ki', '1 kgs': '1Ki',
|
|
27
|
+
// 2 Kings
|
|
28
|
+
'2ki': '2Ki', '2kgs': '2Ki', '2kings': '2Ki', '2 kings': '2Ki', '2 kgs': '2Ki',
|
|
29
|
+
// 1 Chronicles
|
|
30
|
+
'1ch': '1Ch', '1chr': '1Ch', '1chron': '1Ch', '1chronicles': '1Ch', '1 chr': '1Ch', '1 chronicles': '1Ch',
|
|
31
|
+
// 2 Chronicles
|
|
32
|
+
'2ch': '2Ch', '2chr': '2Ch', '2chron': '2Ch', '2chronicles': '2Ch', '2 chr': '2Ch', '2 chronicles': '2Ch',
|
|
33
|
+
// Ezra
|
|
34
|
+
ezr: 'Ezr', ezra: 'Ezr',
|
|
35
|
+
// Nehemiah
|
|
36
|
+
neh: 'Neh', nehemiah: 'Neh',
|
|
37
|
+
// Esther
|
|
38
|
+
est: 'Est', esther: 'Est',
|
|
39
|
+
// Job
|
|
40
|
+
job: 'Job',
|
|
41
|
+
// Psalms
|
|
42
|
+
psa: 'Psa', ps: 'Psa', psalm: 'Psa', psalms: 'Psa',
|
|
43
|
+
// Proverbs
|
|
44
|
+
prv: 'Prv', prov: 'Prv', proverbs: 'Prv',
|
|
45
|
+
// Ecclesiastes
|
|
46
|
+
ecc: 'Ecc', eccl: 'Ecc', ecclesiastes: 'Ecc', qoh: 'Ecc',
|
|
47
|
+
// Song of Songs
|
|
48
|
+
sos: 'SoS', ss: 'SoS', sg: 'SoS', cant: 'SoS', canticles: 'SoS',
|
|
49
|
+
song: 'SoS', 'song of songs': 'SoS', 'song of solomon': 'SoS', 'songs': 'SoS',
|
|
50
|
+
// Isaiah
|
|
51
|
+
isa: 'Isa', isaiah: 'Isa',
|
|
52
|
+
// Jeremiah
|
|
53
|
+
jer: 'Jer', jeremiah: 'Jer',
|
|
54
|
+
// Lamentations
|
|
55
|
+
lam: 'Lam', lamentations: 'Lam',
|
|
56
|
+
// Ezekiel
|
|
57
|
+
ezk: 'Ezk', ezek: 'Ezk', ezekiel: 'Ezk',
|
|
58
|
+
// Daniel
|
|
59
|
+
dan: 'Dan', daniel: 'Dan',
|
|
60
|
+
// Hosea
|
|
61
|
+
hos: 'Hos', hosea: 'Hos',
|
|
62
|
+
// Joel
|
|
63
|
+
joe: 'Joe', joel: 'Joe',
|
|
64
|
+
// Amos
|
|
65
|
+
amo: 'Amo', amos: 'Amo',
|
|
66
|
+
// Obadiah
|
|
67
|
+
oba: 'Oba', obad: 'Oba', obadiah: 'Oba',
|
|
68
|
+
// Jonah
|
|
69
|
+
jon: 'Jon', jonah: 'Jon',
|
|
70
|
+
// Micah
|
|
71
|
+
mic: 'Mic', micah: 'Mic',
|
|
72
|
+
// Nahum
|
|
73
|
+
nah: 'Nah', nahum: 'Nah',
|
|
74
|
+
// Habakkuk
|
|
75
|
+
hab: 'Hab', habakkuk: 'Hab',
|
|
76
|
+
// Zephaniah
|
|
77
|
+
zep: 'Zep', zeph: 'Zep', zephaniah: 'Zep',
|
|
78
|
+
// Haggai
|
|
79
|
+
hag: 'Hag', haggai: 'Hag',
|
|
80
|
+
// Zechariah
|
|
81
|
+
zec: 'Zec', zech: 'Zec', zechariah: 'Zec',
|
|
82
|
+
// Malachi
|
|
83
|
+
mal: 'Mal', malachi: 'Mal',
|
|
84
|
+
// Matthew
|
|
85
|
+
mat: 'Mat', matt: 'Mat', mt: 'Mat', matthew: 'Mat',
|
|
86
|
+
// Mark
|
|
87
|
+
mrk: 'Mrk', mar: 'Mrk', mk: 'Mrk', mark: 'Mrk',
|
|
88
|
+
// Luke
|
|
89
|
+
luk: 'Luk', lk: 'Luk', luke: 'Luk',
|
|
90
|
+
// John
|
|
91
|
+
joh: 'Joh', jn: 'Joh', john: 'Joh',
|
|
92
|
+
// Acts
|
|
93
|
+
act: 'Act', ac: 'Act', acts: 'Act',
|
|
94
|
+
// Romans
|
|
95
|
+
rom: 'Rom', rm: 'Rom', romans: 'Rom',
|
|
96
|
+
// 1 Corinthians
|
|
97
|
+
'1co': '1Co', '1cor': '1Co', '1corinthians': '1Co', '1 cor': '1Co', '1 corinthians': '1Co',
|
|
98
|
+
// 2 Corinthians
|
|
99
|
+
'2co': '2Co', '2cor': '2Co', '2corinthians': '2Co', '2 cor': '2Co', '2 corinthians': '2Co',
|
|
100
|
+
// Galatians
|
|
101
|
+
gal: 'Gal', galatians: 'Gal',
|
|
102
|
+
// Ephesians
|
|
103
|
+
eph: 'Eph', ephesians: 'Eph',
|
|
104
|
+
// Philippians
|
|
105
|
+
phi: 'Phi', phil: 'Phi', php: 'Phi', philippians: 'Phi',
|
|
106
|
+
// Colossians
|
|
107
|
+
col: 'Col', colossians: 'Col',
|
|
108
|
+
// 1 Thessalonians
|
|
109
|
+
'1th': '1Th', '1thes': '1Th', '1thess': '1Th', '1thessalonians': '1Th',
|
|
110
|
+
'1 thes': '1Th', '1 thess': '1Th', '1 thessalonians': '1Th',
|
|
111
|
+
// 2 Thessalonians
|
|
112
|
+
'2th': '2Th', '2thes': '2Th', '2thess': '2Th', '2thessalonians': '2Th',
|
|
113
|
+
'2 thes': '2Th', '2 thess': '2Th', '2 thessalonians': '2Th',
|
|
114
|
+
// 1 Timothy
|
|
115
|
+
'1ti': '1Ti', '1tim': '1Ti', '1timothy': '1Ti', '1 tim': '1Ti', '1 timothy': '1Ti',
|
|
116
|
+
// 2 Timothy
|
|
117
|
+
'2ti': '2Ti', '2tim': '2Ti', '2timothy': '2Ti', '2 tim': '2Ti', '2 timothy': '2Ti',
|
|
118
|
+
// Titus
|
|
119
|
+
tit: 'Tit', titus: 'Tit',
|
|
120
|
+
// Philemon
|
|
121
|
+
phm: 'Phm', phile: 'Phm', philemon: 'Phm',
|
|
122
|
+
// Hebrews
|
|
123
|
+
heb: 'Heb', hebrews: 'Heb',
|
|
124
|
+
// James
|
|
125
|
+
jam: 'Jam', jas: 'Jam', james: 'Jam',
|
|
126
|
+
// 1 Peter
|
|
127
|
+
'1pe': '1Pe', '1pet': '1Pe', '1peter': '1Pe', '1 pet': '1Pe', '1 peter': '1Pe',
|
|
128
|
+
// 2 Peter
|
|
129
|
+
'2pe': '2Pe', '2pet': '2Pe', '2peter': '2Pe', '2 pet': '2Pe', '2 peter': '2Pe',
|
|
130
|
+
// 1 John
|
|
131
|
+
'1jo': '1Jo', '1jn': '1Jo', '1john': '1Jo', '1 jn': '1Jo', '1 john': '1Jo',
|
|
132
|
+
// 2 John
|
|
133
|
+
'2jo': '2Jo', '2jn': '2Jo', '2john': '2Jo', '2 jn': '2Jo', '2 john': '2Jo',
|
|
134
|
+
// 3 John
|
|
135
|
+
'3jo': '3Jo', '3jn': '3Jo', '3john': '3Jo', '3 jn': '3Jo', '3 john': '3Jo',
|
|
136
|
+
// Jude
|
|
137
|
+
jud: 'Jud', jude: 'Jud',
|
|
138
|
+
// Revelation
|
|
139
|
+
rev: 'Rev', revelation: 'Rev', revelations: 'Rev',
|
|
140
|
+
};
|
|
141
|
+
// Resolve a user-typed book name to a canonical abbreviation.
|
|
142
|
+
// Tries exact match, then prefix match on known aliases.
|
|
143
|
+
export function resolveBook(input) {
|
|
144
|
+
const normalized = input.toLowerCase().trim().replace(/\.+$/, '');
|
|
145
|
+
if (ALIASES[normalized])
|
|
146
|
+
return ALIASES[normalized];
|
|
147
|
+
// Prefix match: "gen" matches "genesis", "phil" matches "philippians"
|
|
148
|
+
// but be careful not to match "ph" → both Phi and Phm
|
|
149
|
+
const prefixMatches = Object.keys(ALIASES).filter(k => k.startsWith(normalized));
|
|
150
|
+
if (prefixMatches.length === 1)
|
|
151
|
+
return ALIASES[prefixMatches[0]];
|
|
152
|
+
// If multiple prefix matches, only accept if they all resolve to the same book
|
|
153
|
+
if (prefixMatches.length > 1) {
|
|
154
|
+
const targets = [...new Set(prefixMatches.map(k => ALIASES[k]))];
|
|
155
|
+
if (targets.length === 1)
|
|
156
|
+
return targets[0];
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// Parse the verse portion of a reference (after "chapter:")
|
|
161
|
+
function parseVerseSpec(verseStr) {
|
|
162
|
+
if (verseStr.includes(',')) {
|
|
163
|
+
return { type: 'list', verses: verseStr.split(',').map(v => v.trim()) };
|
|
164
|
+
}
|
|
165
|
+
if (verseStr.includes('-')) {
|
|
166
|
+
const dashIdx = verseStr.indexOf('-');
|
|
167
|
+
return {
|
|
168
|
+
type: 'range',
|
|
169
|
+
start: verseStr.slice(0, dashIdx).trim(),
|
|
170
|
+
end: verseStr.slice(dashIdx + 1).trim(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { type: 'single', verse: verseStr.trim() };
|
|
174
|
+
}
|
|
175
|
+
// Parse a single Bible reference string into a structured object.
|
|
176
|
+
// Input is a joined string e.g. "john 3", "john 3:16", "john 3:16-18", "1co 13:4,7"
|
|
177
|
+
export function parseRef(input) {
|
|
178
|
+
const trimmed = input.trim();
|
|
179
|
+
// Split book name from chapter[:verse] at the end.
|
|
180
|
+
// The chapter:verse part always starts with a digit.
|
|
181
|
+
// We greedily consume from the right: last token starting with a digit is the ref.
|
|
182
|
+
const match = trimmed.match(/^(.*?)\s+(\d+(?::\S+)?)$/);
|
|
183
|
+
if (!match) {
|
|
184
|
+
throw new Error(`Cannot parse reference: "${input}"`);
|
|
185
|
+
}
|
|
186
|
+
const bookStr = match[1].trim();
|
|
187
|
+
const chapterVerseStr = match[2];
|
|
188
|
+
const book = resolveBook(bookStr);
|
|
189
|
+
if (!book) {
|
|
190
|
+
throw new Error(`Unknown book: "${bookStr}"`);
|
|
191
|
+
}
|
|
192
|
+
const colonIdx = chapterVerseStr.indexOf(':');
|
|
193
|
+
if (colonIdx === -1) {
|
|
194
|
+
return { book, chapter: parseInt(chapterVerseStr, 10) };
|
|
195
|
+
}
|
|
196
|
+
const chapter = parseInt(chapterVerseStr.slice(0, colonIdx), 10);
|
|
197
|
+
let verseStr = chapterVerseStr.slice(colonIdx + 1);
|
|
198
|
+
// Normalize "chapter:verse-chapter:verse" → "verse-verse" when both chapters match.
|
|
199
|
+
// e.g. "2:2-2:5" → verseStr goes from "2-2:5" to "2-5"
|
|
200
|
+
if (verseStr.includes('-')) {
|
|
201
|
+
const dashIdx = verseStr.indexOf('-');
|
|
202
|
+
const end = verseStr.slice(dashIdx + 1);
|
|
203
|
+
if (end.includes(':')) {
|
|
204
|
+
const endColonIdx = end.indexOf(':');
|
|
205
|
+
if (parseInt(end.slice(0, endColonIdx), 10) === chapter) {
|
|
206
|
+
verseStr = verseStr.slice(0, dashIdx + 1) + end.slice(endColonIdx + 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { book, chapter, verses: parseVerseSpec(verseStr) };
|
|
211
|
+
}
|
|
212
|
+
// Parse an array of CLI tokens (from commander's variadic arg) into a ParsedRef.
|
|
213
|
+
// Joins tokens and delegates to parseRef.
|
|
214
|
+
// e.g. ['john', '3:16'] → parseRef('john 3:16')
|
|
215
|
+
export function parseRefTokens(tokens) {
|
|
216
|
+
return parseRef(tokens.join(' '));
|
|
217
|
+
}
|
|
218
|
+
// Parse a multi-ref token list into an array of ParsedRefs.
|
|
219
|
+
// Handles:
|
|
220
|
+
// - Basic: ['john', '3:16,18'] → 1 list ref
|
|
221
|
+
// - Inherited: ['john', '3:16', '3:18'] → 2 refs (book inherited)
|
|
222
|
+
// - Bare verse: ['Ezek.', '14:14,', '20;', ...] → verse 20 inherits chapter 14
|
|
223
|
+
// - Semicolons: reset chapter context (start of a new book:chapter group)
|
|
224
|
+
// - Periods: stripped from book abbreviations (e.g. 'Ezek.' → 'Ezek')
|
|
225
|
+
// - Quoted: single-token compound strings are whitespace-split before parsing
|
|
226
|
+
export function parseRefList(tokens) {
|
|
227
|
+
const refs = [];
|
|
228
|
+
// Normalize: expand semicolons into separator markers, split on whitespace
|
|
229
|
+
// (handles quoted compound strings like "Ezek. 14:14, 20; Gen. 6:8"),
|
|
230
|
+
// then strip trailing commas and periods from each piece.
|
|
231
|
+
const normalized = tokens
|
|
232
|
+
.flatMap(t => {
|
|
233
|
+
const semiParts = t.split(';');
|
|
234
|
+
return semiParts.flatMap((p, idx) => {
|
|
235
|
+
const marker = idx < semiParts.length - 1 ? [';'] : [];
|
|
236
|
+
return [...p.split(/\s+/), ...marker];
|
|
237
|
+
});
|
|
238
|
+
})
|
|
239
|
+
.map(t => t.replace(/,+$/, '').replace(/\.+$/, '').trim())
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
let i = 0;
|
|
242
|
+
let lastBookRaw = null; // raw (period-stripped) name for parseRef reuse
|
|
243
|
+
let lastBookAbbr = null; // canonical abbreviation
|
|
244
|
+
let lastChapter = null;
|
|
245
|
+
let lastHadVerse = false;
|
|
246
|
+
while (i < normalized.length) {
|
|
247
|
+
const token = normalized[i];
|
|
248
|
+
// Semicolon = reference group boundary: reset verse context so bare numbers
|
|
249
|
+
// after the semicolon don't incorrectly inherit the previous chapter.
|
|
250
|
+
if (token === ';') {
|
|
251
|
+
lastChapter = null;
|
|
252
|
+
lastHadVerse = false;
|
|
253
|
+
i++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// Bare verse number or range (e.g. "20" or "37-39") following a verse-level ref:
|
|
257
|
+
// inherit book + chapter from previous ref. "14:14, 20" → verse 14 AND verse 20.
|
|
258
|
+
if (/^\d+(-\d+)?$/.test(token) && lastBookAbbr !== null && lastHadVerse && lastChapter !== null) {
|
|
259
|
+
const dashIdx = token.indexOf('-');
|
|
260
|
+
if (dashIdx !== -1) {
|
|
261
|
+
refs.push({
|
|
262
|
+
book: lastBookAbbr,
|
|
263
|
+
chapter: lastChapter,
|
|
264
|
+
verses: { type: 'range', start: token.slice(0, dashIdx), end: token.slice(dashIdx + 1) },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
refs.push({ book: lastBookAbbr, chapter: lastChapter, verses: { type: 'single', verse: token } });
|
|
269
|
+
}
|
|
270
|
+
i++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Token starts with a digit and we have a prior book: try as chapter[:verse]
|
|
274
|
+
if (/^\d/.test(token) && lastBookRaw !== null) {
|
|
275
|
+
try {
|
|
276
|
+
const ref = parseRef(`${lastBookRaw} ${token}`);
|
|
277
|
+
refs.push(ref);
|
|
278
|
+
lastBookAbbr = ref.book;
|
|
279
|
+
lastChapter = ref.chapter;
|
|
280
|
+
lastHadVerse = !!ref.verses;
|
|
281
|
+
i++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// fall through to book-name scan
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Scan 1–3 tokens as a book name followed by a chapter[:verse] token
|
|
289
|
+
let found = false;
|
|
290
|
+
for (let nameLen = 3; nameLen >= 1; nameLen--) {
|
|
291
|
+
if (i + nameLen >= normalized.length)
|
|
292
|
+
continue;
|
|
293
|
+
const namePart = normalized.slice(i, i + nameLen).join(' ');
|
|
294
|
+
const chapterToken = normalized[i + nameLen];
|
|
295
|
+
if (!/^\d/.test(chapterToken))
|
|
296
|
+
continue;
|
|
297
|
+
const book = resolveBook(namePart);
|
|
298
|
+
if (book) {
|
|
299
|
+
try {
|
|
300
|
+
const ref = parseRef(`${namePart} ${chapterToken}`);
|
|
301
|
+
refs.push(ref);
|
|
302
|
+
lastBookRaw = namePart;
|
|
303
|
+
lastBookAbbr = ref.book;
|
|
304
|
+
lastChapter = ref.chapter;
|
|
305
|
+
lastHadVerse = !!ref.verses;
|
|
306
|
+
i += nameLen + 1;
|
|
307
|
+
found = true;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// continue scanning
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!found) {
|
|
316
|
+
throw new Error(`Cannot parse reference near: "${token}"`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return refs;
|
|
320
|
+
}
|
package/dist/state.d.ts
ADDED
package/dist/state.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import toml from 'toml';
|
|
5
|
+
const STATE_DIR = join(homedir(), '.rv');
|
|
6
|
+
const STATE_FILE = join(STATE_DIR, 'state.toml');
|
|
7
|
+
export function getLastRead() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = readFileSync(STATE_FILE, 'utf-8');
|
|
10
|
+
const parsed = toml.parse(raw);
|
|
11
|
+
const lr = parsed?.last_read;
|
|
12
|
+
if (lr?.book && typeof lr.chapter === 'number') {
|
|
13
|
+
return { book: lr.book, chapter: lr.chapter };
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveLastRead(book, chapter) {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
24
|
+
const content = `[last_read]\nbook = "${book}"\nchapter = ${chapter}\n`;
|
|
25
|
+
writeFileSync(STATE_FILE, content, 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// silently ignore — state is non-critical
|
|
29
|
+
}
|
|
30
|
+
}
|