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/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
+ }
@@ -0,0 +1,6 @@
1
+ export interface LastRead {
2
+ book: string;
3
+ chapter: number;
4
+ }
5
+ export declare function getLastRead(): LastRead | null;
6
+ export declare function saveLastRead(book: string, chapter: number): void;
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
+ }
@@ -0,0 +1,5 @@
1
+ export declare function launchPager(book: string, chapter: number, opts: {
2
+ notes: boolean;
3
+ outline: boolean;
4
+ }): Promise<void>;
5
+ export declare function launchPagerHome(): Promise<void>;