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/index.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import clipboard from 'clipboardy';
|
|
5
|
+
import { parseRefList, resolveBook } from './parser.js';
|
|
6
|
+
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getFootnote, getVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from './db.js';
|
|
7
|
+
import { renderVerses, renderVerseInline, renderFootnoteBlock, renderNoteDisplay, renderNoteDisplayAll, stripMarkers, highlightTerms, } from './format.js';
|
|
8
|
+
import { getLastRead, saveLastRead } from './state.js';
|
|
9
|
+
// Strip ANSI escape codes — cleans rendered output before copying to clipboard
|
|
10
|
+
function stripAnsi(s) {
|
|
11
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
12
|
+
}
|
|
13
|
+
program
|
|
14
|
+
.name('rv')
|
|
15
|
+
.description('Recovery Version Bible CLI')
|
|
16
|
+
.version('0.1.0')
|
|
17
|
+
.enablePositionalOptions();
|
|
18
|
+
// Formats a ParsedRef + book name into a display label: "John 3", "John 3:16", "John 3:16–18"
|
|
19
|
+
function formatRefLabel(ref, bookName) {
|
|
20
|
+
if (!ref.verses)
|
|
21
|
+
return `${bookName} ${ref.chapter}`;
|
|
22
|
+
if (ref.verses.type === 'single')
|
|
23
|
+
return `${bookName} ${ref.chapter}:${ref.verses.verse}`;
|
|
24
|
+
if (ref.verses.type === 'range')
|
|
25
|
+
return `${bookName} ${ref.chapter}:${ref.verses.start}–${ref.verses.end}`;
|
|
26
|
+
return `${bookName} ${ref.chapter}:${ref.verses.verses.join(', ')}`;
|
|
27
|
+
}
|
|
28
|
+
// Generates clean plain-text copy string for verse mode, respecting format flags.
|
|
29
|
+
// opts.ref: true = include "Book ch:v" prefix (default), false = --no-ref
|
|
30
|
+
// opts.numbered: prefix with verse number only
|
|
31
|
+
// opts.md: markdown block-quote format
|
|
32
|
+
function versesToCopyText(verses, bookName, opts) {
|
|
33
|
+
if (opts.md) {
|
|
34
|
+
const text = verses.map(v => stripMarkers(v.text)).join(' ');
|
|
35
|
+
const refLabel = verses.length === 1
|
|
36
|
+
? `${bookName} ${verses[0].chapter}:${verses[0].verse}`
|
|
37
|
+
: `${bookName} ${verses[0].chapter}:${verses[0].verse}–${verses[verses.length - 1].verse}`;
|
|
38
|
+
return `> ${text}\n> — ${refLabel}`;
|
|
39
|
+
}
|
|
40
|
+
return verses.map(v => {
|
|
41
|
+
const text = stripMarkers(v.text);
|
|
42
|
+
if (!opts.ref)
|
|
43
|
+
return text;
|
|
44
|
+
if (opts.numbered)
|
|
45
|
+
return `${v.verse} ${text}`;
|
|
46
|
+
return `${bookName} ${v.chapter}:${v.verse} ${text}`;
|
|
47
|
+
}).join('\n');
|
|
48
|
+
}
|
|
49
|
+
async function writeClipboard(text, label) {
|
|
50
|
+
await clipboard.write(text);
|
|
51
|
+
process.stderr.write(chalk.dim(`✓ Copied ${label} to clipboard\n`));
|
|
52
|
+
}
|
|
53
|
+
// Interactive "press for more" prompt between result pages.
|
|
54
|
+
// Returns true = show next page, false = stop. Ctrl+C exits the process.
|
|
55
|
+
async function promptForMore(shown, total) {
|
|
56
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
57
|
+
return false;
|
|
58
|
+
return new Promise(resolve => {
|
|
59
|
+
process.stderr.write(chalk.dim(`\n ── ${shown} of ${total} ── Space/Enter next page · q quit ──`));
|
|
60
|
+
process.stdin.setRawMode(true);
|
|
61
|
+
process.stdin.resume();
|
|
62
|
+
process.stdin.once('data', (key) => {
|
|
63
|
+
process.stdin.setRawMode(false);
|
|
64
|
+
process.stdin.pause();
|
|
65
|
+
process.stderr.write('\r\x1b[2K'); // erase prompt line
|
|
66
|
+
const ch = key.toString();
|
|
67
|
+
if (ch === '\x03') {
|
|
68
|
+
process.stderr.write('\n');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
} // Ctrl+C
|
|
71
|
+
resolve(ch === '\r' || ch === '\n' || ch === ' ' || ch === 'n');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// ── Homepage (rv with no args) ───────────────────────────────────────────────
|
|
76
|
+
function renderHomepage() {
|
|
77
|
+
const COLS = 12; // books per row
|
|
78
|
+
const COL_W = 5; // column width (3-char abbr + padding)
|
|
79
|
+
const lines = [];
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push(chalk.bold(' Recovery Version Bible'));
|
|
82
|
+
lines.push('');
|
|
83
|
+
// Last-read context
|
|
84
|
+
const last = getLastRead();
|
|
85
|
+
if (last) {
|
|
86
|
+
const bookName = getBookInfo(last.book)?.full_name ?? last.book;
|
|
87
|
+
lines.push(chalk.dim(` Last read: ${bookName} ${last.chapter}`));
|
|
88
|
+
lines.push(chalk.dim(' › rv continue'));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
lines.push(chalk.dim(' Get started: rv john 1'));
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
// Book grid
|
|
95
|
+
const books = getAllBooks();
|
|
96
|
+
const ot = books.filter(b => b.testament === 'OT');
|
|
97
|
+
const nt = books.filter(b => b.testament === 'NT');
|
|
98
|
+
function renderGrid(bookList) {
|
|
99
|
+
const gridLines = [];
|
|
100
|
+
for (let i = 0; i < bookList.length; i += COLS) {
|
|
101
|
+
const row = bookList.slice(i, i + COLS);
|
|
102
|
+
gridLines.push(' ' + row.map(b => b.abbr.padEnd(COL_W)).join(''));
|
|
103
|
+
}
|
|
104
|
+
return gridLines;
|
|
105
|
+
}
|
|
106
|
+
lines.push(chalk.dim(' OLD TESTAMENT'));
|
|
107
|
+
lines.push(...renderGrid(ot));
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(chalk.dim(' NEW TESTAMENT'));
|
|
110
|
+
lines.push(...renderGrid(nt));
|
|
111
|
+
lines.push('');
|
|
112
|
+
// Hints
|
|
113
|
+
lines.push(chalk.dim(' rv <book> <chapter> rv search <query> rv continue'));
|
|
114
|
+
lines.push('');
|
|
115
|
+
console.log(lines.join('\n'));
|
|
116
|
+
}
|
|
117
|
+
// ── rv <ref> [--notes] [--outline] [--full] [--copy] ─────────────────────────
|
|
118
|
+
program
|
|
119
|
+
.argument('[ref...]', 'Bible reference (e.g. john 3, john 3:16, john 3:16-18)')
|
|
120
|
+
.option('--notes', 'show footnote markers inline + footnote block below')
|
|
121
|
+
.option('--outline', 'show section headers inline (chapter mode only)')
|
|
122
|
+
.option('--full', 'show both section headers and footnotes (shorthand for --outline --notes)')
|
|
123
|
+
.option('--copy', 'copy displayed output to clipboard')
|
|
124
|
+
.option('--no-ref', 'with --copy: omit reference prefix (verse mode)')
|
|
125
|
+
.option('--numbered', 'with --copy: prefix with verse number only (verse mode)')
|
|
126
|
+
.option('--md', 'with --copy: markdown block-quote format (verse mode)')
|
|
127
|
+
.option('--raw', 'plain text output, no pager (default for now)')
|
|
128
|
+
.action(async (refTokens, opts) => {
|
|
129
|
+
if (opts.full) {
|
|
130
|
+
opts.notes = true;
|
|
131
|
+
opts.outline = true;
|
|
132
|
+
}
|
|
133
|
+
if (refTokens.length === 0) {
|
|
134
|
+
if (process.stdout.isTTY) {
|
|
135
|
+
const { launchPagerHome } = await import('./ui/Pager.js');
|
|
136
|
+
await launchPagerHome();
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
renderHomepage();
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
let refs;
|
|
144
|
+
try {
|
|
145
|
+
refs = parseRefList(refTokens);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
console.error(`Error: ${e.message}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
// Chapter mode: full display with title + optional headers/footnotes
|
|
152
|
+
// Verse mode: compact inline "Book ch:v text", one line per verse
|
|
153
|
+
const isChapterMode = refs.every(r => !r.verses);
|
|
154
|
+
// Format flags only apply to verse-mode copy text
|
|
155
|
+
const hasCopyFormat = !opts.ref || !!opts.numbered || !!opts.md;
|
|
156
|
+
const outputs = [];
|
|
157
|
+
const copyOutputs = []; // only populated for verse mode + copy format flags
|
|
158
|
+
const copyLabels = [];
|
|
159
|
+
if (isChapterMode) {
|
|
160
|
+
for (const ref of refs) {
|
|
161
|
+
const verses = getVersesByRef(ref);
|
|
162
|
+
if (verses.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
|
|
165
|
+
const headers = opts.outline ? getSectionHeaders(ref.book) : [];
|
|
166
|
+
const footnotes = opts.notes ? getFootnotesForChapter(ref.book, ref.chapter) : [];
|
|
167
|
+
const label = formatRefLabel(ref, bookName);
|
|
168
|
+
outputs.push(renderVerses(verses, headers, footnotes, {
|
|
169
|
+
notes: opts.notes ?? false,
|
|
170
|
+
title: label,
|
|
171
|
+
}));
|
|
172
|
+
copyLabels.push(label);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
for (const ref of refs) {
|
|
177
|
+
const verses = getVersesByRef(ref);
|
|
178
|
+
if (verses.length === 0)
|
|
179
|
+
continue;
|
|
180
|
+
const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
|
|
181
|
+
const notes = opts.notes ?? false;
|
|
182
|
+
const lines = verses.map(v => renderVerseInline(v, bookName, notes));
|
|
183
|
+
if (notes) {
|
|
184
|
+
const footnotes = getFootnotesForVerses(ref.book, ref.chapter, verses.map(v => v.verse));
|
|
185
|
+
const inlineMarkers = new Set(verses.flatMap(v => [...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1])));
|
|
186
|
+
const scoped = footnotes.filter(f => inlineMarkers.has(f.marker));
|
|
187
|
+
if (scoped.length > 0)
|
|
188
|
+
lines.push('', renderFootnoteBlock(scoped));
|
|
189
|
+
}
|
|
190
|
+
outputs.push(lines.join('\n'));
|
|
191
|
+
if (opts.copy && hasCopyFormat) {
|
|
192
|
+
copyOutputs.push(versesToCopyText(verses, bookName, {
|
|
193
|
+
ref: opts.ref, numbered: opts.numbered, md: opts.md,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
copyLabels.push(formatRefLabel(ref, bookName));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (outputs.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
// Pager: chapter mode + TTY + no --raw + no --copy → launch interactive pager
|
|
202
|
+
if (isChapterMode && process.stdout.isTTY && !opts.raw && !opts.copy) {
|
|
203
|
+
const { launchPager } = await import('./ui/Pager.js');
|
|
204
|
+
const ref = refs[0];
|
|
205
|
+
saveLastRead(ref.book, ref.chapter);
|
|
206
|
+
await launchPager(ref.book, ref.chapter, {
|
|
207
|
+
notes: opts.notes ?? false,
|
|
208
|
+
outline: opts.outline ?? false,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const displayed = outputs.join('\n\n');
|
|
213
|
+
console.log(displayed);
|
|
214
|
+
// Auto-track: save the last ref read (first ref's book + chapter)
|
|
215
|
+
const lastRef = refs[0];
|
|
216
|
+
saveLastRead(lastRef.book, lastRef.chapter);
|
|
217
|
+
if (opts.copy) {
|
|
218
|
+
const copyText = (hasCopyFormat && !isChapterMode && copyOutputs.length > 0)
|
|
219
|
+
? copyOutputs.join('\n\n')
|
|
220
|
+
: stripAnsi(displayed);
|
|
221
|
+
await writeClipboard(copyText, copyLabels.join(', '));
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
// ── rv note john 3:16 [marker] [--copy] ──────────────────────────────────────
|
|
225
|
+
program
|
|
226
|
+
.command('note')
|
|
227
|
+
.argument('<args...>', 'verse ref + optional marker, e.g. john 3:16 or john 3:16 1a')
|
|
228
|
+
.description('Show footnote(s) for a verse or range')
|
|
229
|
+
.option('--copy', 'copy displayed output to clipboard')
|
|
230
|
+
.action(async (args, opts) => {
|
|
231
|
+
if (args.length === 0) {
|
|
232
|
+
console.error('Usage: rv note <ref> [marker] (e.g. rv note john 3:16 or rv note john 3:16 1a)');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
// Try no-marker mode: parse all args as verse refs (every ref must have verses).
|
|
236
|
+
let noMarkerRefs = null;
|
|
237
|
+
try {
|
|
238
|
+
const parsed = parseRefList(args);
|
|
239
|
+
if (parsed.length >= 1 && parsed.every(r => r.verses))
|
|
240
|
+
noMarkerRefs = parsed;
|
|
241
|
+
}
|
|
242
|
+
catch { /* fall through to marker mode */ }
|
|
243
|
+
if (noMarkerRefs) {
|
|
244
|
+
const blocks = [];
|
|
245
|
+
const copyLabels = [];
|
|
246
|
+
for (const ref of noMarkerRefs) {
|
|
247
|
+
const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
|
|
248
|
+
for (const v of getVersesByRef(ref)) {
|
|
249
|
+
const footnotes = getFootnotesForVerses(ref.book, ref.chapter, [v.verse]);
|
|
250
|
+
const inlineMarkers = new Set([...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1]));
|
|
251
|
+
blocks.push(renderNoteDisplayAll(v, footnotes.filter(f => inlineMarkers.has(f.marker)), bookName));
|
|
252
|
+
copyLabels.push(`${bookName} ${v.chapter}:${v.verse}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (blocks.length > 0) {
|
|
256
|
+
const sep = '\n' + chalk.dim('─'.repeat(process.stdout.columns ?? 60)) + '\n';
|
|
257
|
+
const output = blocks.join(sep);
|
|
258
|
+
console.log(output);
|
|
259
|
+
if (opts.copy)
|
|
260
|
+
await writeClipboard(stripAnsi(output), copyLabels.join(', '));
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Marker mode: last token is the marker, everything before is the ref.
|
|
265
|
+
if (args.length < 2) {
|
|
266
|
+
console.error('Usage: rv note <ref> <marker> (e.g. rv note john 3:16 1a)');
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const marker = args[args.length - 1];
|
|
270
|
+
const refTokens = args.slice(0, -1);
|
|
271
|
+
let ref;
|
|
272
|
+
try {
|
|
273
|
+
const parsed = parseRefList(refTokens);
|
|
274
|
+
ref = parsed[0];
|
|
275
|
+
if (!ref)
|
|
276
|
+
throw new Error('Could not parse reference');
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
console.error(`Error: ${e.message}`);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
if (!ref.verses || ref.verses.type !== 'single') {
|
|
283
|
+
console.error('Error: note command with a marker requires a single verse (e.g. john 3:16)');
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
const verse = getVerse(ref.book, ref.chapter, ref.verses.verse);
|
|
287
|
+
if (!verse) {
|
|
288
|
+
console.error(`Verse not found: ${refTokens.join(' ')}`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
const fn = getFootnote(ref.book, ref.chapter, ref.verses.verse, marker);
|
|
292
|
+
if (!fn) {
|
|
293
|
+
console.error(`No footnote "${marker}" found for ${refTokens.join(' ')}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
|
|
297
|
+
const output = renderNoteDisplay(verse, fn, bookName);
|
|
298
|
+
console.log(output);
|
|
299
|
+
if (opts.copy) {
|
|
300
|
+
await writeClipboard(stripAnsi(output), `note ${marker} for ${bookName} ${ref.chapter}:${ref.verses.verse}`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
// ── Search & Topic helpers ────────────────────────────────────────────────────
|
|
304
|
+
const RESULT_LIMIT = 20;
|
|
305
|
+
// Scans args right-to-left for "in <book>" scope suffix.
|
|
306
|
+
// "grace in romans" → { queryTokens: ["grace"], book: "Rom" }
|
|
307
|
+
// "sin in john" → { queryTokens: ["sin"], book: "Joh" }
|
|
308
|
+
// "faith in christ" → { queryTokens: ["faith", "in", "christ"], book: undefined }
|
|
309
|
+
function parseScope(args) {
|
|
310
|
+
for (let nameLen = 3; nameLen >= 1; nameLen--) {
|
|
311
|
+
const inIdx = args.length - nameLen - 1;
|
|
312
|
+
if (inIdx < 1)
|
|
313
|
+
continue; // need at least one query token before "in"
|
|
314
|
+
if (args[inIdx]?.toLowerCase() !== 'in')
|
|
315
|
+
continue;
|
|
316
|
+
const book = resolveBook(args.slice(inIdx + 1, inIdx + 1 + nameLen).join(' '));
|
|
317
|
+
if (book)
|
|
318
|
+
return { queryTokens: args.slice(0, inIdx), book };
|
|
319
|
+
}
|
|
320
|
+
return { queryTokens: args, book: undefined };
|
|
321
|
+
}
|
|
322
|
+
// Builds an FTS5 query string and extracts terms for highlighting.
|
|
323
|
+
// Single shell-quoted arg with spaces → phrase: "only begotten"
|
|
324
|
+
// Multiple tokens → AND: "eternal" AND "life"
|
|
325
|
+
function buildFTSQuery(tokens) {
|
|
326
|
+
if (tokens.length === 1 && tokens[0].includes(' ')) {
|
|
327
|
+
const phrase = tokens[0];
|
|
328
|
+
return {
|
|
329
|
+
fts: `"${phrase.replace(/"/g, '')}"`,
|
|
330
|
+
terms: phrase.split(/\s+/).filter(Boolean),
|
|
331
|
+
display: `"${phrase}"`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const terms = tokens.map(t => t.replace(/"/g, '').trim()).filter(Boolean);
|
|
335
|
+
return {
|
|
336
|
+
fts: terms.length === 1 ? `"${terms[0]}"` : terms.map(t => `"${t}"`).join(' AND '),
|
|
337
|
+
terms,
|
|
338
|
+
display: terms.length === 1 ? terms[0] : terms.join(' '),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// ── rv search <query> [in <book>] [--fts] [--all] [--copy] ───────────────────
|
|
342
|
+
// Concordance-first: if the query is a single known concordance word, use the
|
|
343
|
+
// curated concordance index. Otherwise (or with --fts) falls back to FTS5.
|
|
344
|
+
program
|
|
345
|
+
.command('search')
|
|
346
|
+
.argument('<query...>', 'word, phrase, or "quoted phrase", e.g. grace or "only begotten"')
|
|
347
|
+
.description('Search verses — concordance for known words, FTS5 otherwise')
|
|
348
|
+
.option('--fts', 'force raw full-text search, bypass concordance')
|
|
349
|
+
.option('--all', `show all results (default: ${RESULT_LIMIT})`)
|
|
350
|
+
.option('--copy', 'copy results to clipboard')
|
|
351
|
+
.action(async (queryArgs, opts) => {
|
|
352
|
+
const { queryTokens, book } = parseScope(queryArgs);
|
|
353
|
+
if (queryTokens.length === 0) {
|
|
354
|
+
console.error('Usage: rv search <query> [in <book>]');
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
const word = queryTokens.join(' ');
|
|
358
|
+
const scopeLabel = book ? ` in ${getBookInfo(book)?.full_name ?? book}` : '';
|
|
359
|
+
// Concordance path: single-word queries that exist in the curated index,
|
|
360
|
+
// unless the user explicitly wants raw FTS with --fts.
|
|
361
|
+
const useConcordance = !opts.fts && queryTokens.length === 1 && isInConcordance(word);
|
|
362
|
+
let all;
|
|
363
|
+
let terms;
|
|
364
|
+
let sourceLabel;
|
|
365
|
+
if (useConcordance) {
|
|
366
|
+
all = getTopicVerses(word, book);
|
|
367
|
+
terms = [word];
|
|
368
|
+
sourceLabel = `Concordance: ${word}${scopeLabel} · ${all.length} occurrence${all.length === 1 ? '' : 's'}`;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const { fts, terms: ftsTerms, display } = buildFTSQuery(queryTokens);
|
|
372
|
+
all = searchFTS(fts, book);
|
|
373
|
+
terms = ftsTerms;
|
|
374
|
+
const notInNote = !opts.fts && queryTokens.length === 1 ? ' (not in concordance)' : '';
|
|
375
|
+
sourceLabel = all.length > 0
|
|
376
|
+
? `Search: "${display}"${scopeLabel} · ${all.length} match${all.length === 1 ? '' : 'es'}${notInNote}`
|
|
377
|
+
: `"${display}" not found`;
|
|
378
|
+
}
|
|
379
|
+
if (all.length === 0) {
|
|
380
|
+
console.log(chalk.dim(` No results for "${word}"${scopeLabel}`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Print header (total count always visible upfront)
|
|
384
|
+
console.log(chalk.dim(` ${sourceLabel}`));
|
|
385
|
+
console.log('');
|
|
386
|
+
// Paginate: interactive page-by-page in a TTY, single page + hint when piped
|
|
387
|
+
const isTTY = process.stdout.isTTY ?? false;
|
|
388
|
+
let offset = 0;
|
|
389
|
+
while (offset < all.length) {
|
|
390
|
+
const pageEnd = opts.all ? all.length : Math.min(offset + RESULT_LIMIT, all.length);
|
|
391
|
+
for (let i = offset; i < pageEnd; i++) {
|
|
392
|
+
const v = all[i];
|
|
393
|
+
const bookName = getBookInfo(v.book)?.full_name ?? v.book;
|
|
394
|
+
const ref = `${bookName} ${v.chapter}:${v.verse}`;
|
|
395
|
+
const cleanText = stripMarkers(v.text);
|
|
396
|
+
// Truncate to ~150 chars for scanability
|
|
397
|
+
const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
|
|
398
|
+
const highlighted = highlightTerms(truncated, terms);
|
|
399
|
+
// Wrap the text at ~76 chars with 4-space indent
|
|
400
|
+
const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
|
|
401
|
+
const words = highlighted.split(' ');
|
|
402
|
+
const wrapLines = [];
|
|
403
|
+
let cur = '';
|
|
404
|
+
for (const w of words) {
|
|
405
|
+
const wLen = w.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
406
|
+
const curLen = cur.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
407
|
+
if (!cur) {
|
|
408
|
+
cur = w;
|
|
409
|
+
}
|
|
410
|
+
else if (curLen + 1 + wLen <= maxW) {
|
|
411
|
+
cur += ' ' + w;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
wrapLines.push(cur);
|
|
415
|
+
cur = w;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (cur)
|
|
419
|
+
wrapLines.push(cur);
|
|
420
|
+
console.log(` ${chalk.bold(ref)}`);
|
|
421
|
+
for (const line of wrapLines)
|
|
422
|
+
console.log(` ${line}`);
|
|
423
|
+
console.log('');
|
|
424
|
+
}
|
|
425
|
+
offset = pageEnd;
|
|
426
|
+
if (offset >= all.length || opts.all)
|
|
427
|
+
break;
|
|
428
|
+
if (!isTTY) {
|
|
429
|
+
// Non-interactive: show "showing N of M" hint and stop
|
|
430
|
+
const ftsFlag = opts.fts ? ' --fts' : '';
|
|
431
|
+
const cmd = `rv search ${word}${book ? ` in ${(getBookInfo(book)?.full_name ?? book).toLowerCase()}` : ''}${ftsFlag}`;
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log(chalk.dim(` showing ${offset} of ${all.length} · ${cmd} --all to show all`));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
const more = await promptForMore(offset, all.length);
|
|
437
|
+
if (!more)
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
// --copy always copies the full result set regardless of how many pages were viewed
|
|
441
|
+
if (opts.copy) {
|
|
442
|
+
const copyLines = all.map(v => {
|
|
443
|
+
const bookName = getBookInfo(v.book)?.full_name ?? v.book;
|
|
444
|
+
return `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`;
|
|
445
|
+
});
|
|
446
|
+
await writeClipboard(copyLines.join('\n'), `${word} (${all.length} verse${all.length === 1 ? '' : 's'})`);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
// ── rv continue ───────────────────────────────────────────────────────────────
|
|
450
|
+
program
|
|
451
|
+
.command('continue')
|
|
452
|
+
.description('Re-display last read passage')
|
|
453
|
+
.option('--notes', 'show footnote markers inline + footnote block below')
|
|
454
|
+
.option('--outline', 'show section headers inline')
|
|
455
|
+
.option('--full', 'shorthand for --outline --notes')
|
|
456
|
+
.option('--copy', 'copy displayed output to clipboard')
|
|
457
|
+
.action(async (opts) => {
|
|
458
|
+
const last = getLastRead();
|
|
459
|
+
if (!last) {
|
|
460
|
+
console.log(chalk.dim(' Nothing read yet — try: rv john 1'));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (opts.full) {
|
|
464
|
+
opts.notes = true;
|
|
465
|
+
opts.outline = true;
|
|
466
|
+
}
|
|
467
|
+
// Pager for continue: TTY + no --copy → launch pager
|
|
468
|
+
if (process.stdout.isTTY && !opts.copy) {
|
|
469
|
+
const { launchPager } = await import('./ui/Pager.js');
|
|
470
|
+
await launchPager(last.book, last.chapter, {
|
|
471
|
+
notes: opts.notes ?? false,
|
|
472
|
+
outline: opts.outline ?? false,
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const bookInfo = getBookInfo(last.book);
|
|
477
|
+
const bookName = bookInfo?.full_name ?? last.book;
|
|
478
|
+
const ref = { book: last.book, chapter: last.chapter };
|
|
479
|
+
const verses = getVersesByRef(ref);
|
|
480
|
+
if (verses.length === 0) {
|
|
481
|
+
console.error(`Could not load ${bookName} ${last.chapter}`);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
const headers = opts.outline ? getSectionHeaders(last.book) : [];
|
|
485
|
+
const footnotes = opts.notes ? getFootnotesForChapter(last.book, last.chapter) : [];
|
|
486
|
+
const label = `${bookName} ${last.chapter}`;
|
|
487
|
+
console.log(chalk.dim(` Continuing: ${label}`));
|
|
488
|
+
console.log('');
|
|
489
|
+
const output = renderVerses(verses, headers, footnotes, {
|
|
490
|
+
notes: opts.notes ?? false,
|
|
491
|
+
title: label,
|
|
492
|
+
});
|
|
493
|
+
console.log(output);
|
|
494
|
+
if (opts.copy) {
|
|
495
|
+
await writeClipboard(stripAnsi(output), label);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
// ── rv help ──────────────────────────────────────────────────────────────────
|
|
499
|
+
program
|
|
500
|
+
.command('help')
|
|
501
|
+
.description('Show all commands and pager shortcuts')
|
|
502
|
+
.action(() => {
|
|
503
|
+
const d = chalk.dim;
|
|
504
|
+
const b = chalk.bold;
|
|
505
|
+
const c = chalk.cyan;
|
|
506
|
+
console.log(`
|
|
507
|
+
${b('rv — Recovery Version Bible CLI')}
|
|
508
|
+
|
|
509
|
+
${b('READING')}
|
|
510
|
+
${c('rv john 3')} Read a chapter (opens pager in terminal)
|
|
511
|
+
${c('rv john 3:16')} Read a single verse
|
|
512
|
+
${c('rv john 3:16-18')} Read a verse range
|
|
513
|
+
${c('rv john 3:16,18,20')} Multiple specific verses
|
|
514
|
+
${c('rv john 3:16 rom 8:28')} Verses across books
|
|
515
|
+
${c('rv jn 3')} Fuzzy book names ${d('(jn → John, gen → Genesis, rm → Romans)')}
|
|
516
|
+
${c('rv john 3 --notes')} Show footnote markers + footnote block
|
|
517
|
+
${c('rv john 3 --outline')} Show section headers inline
|
|
518
|
+
${c('rv john 3 --full')} Both notes and outline
|
|
519
|
+
${c('rv john 3 --raw')} Plain text, no pager ${d('(also auto when piped)')}
|
|
520
|
+
|
|
521
|
+
${b('FOOTNOTES')}
|
|
522
|
+
${c('rv note john 3:16')} All footnotes for a verse
|
|
523
|
+
${c('rv note john 3:16 2d')} Specific footnote by marker
|
|
524
|
+
${c('rv note john 3:14-16')} Footnotes for a range
|
|
525
|
+
|
|
526
|
+
${b('SEARCH')} ${d('concordance-first for known words, FTS5 for everything else')}
|
|
527
|
+
${c('rv search grace')} Concordance lookup ${d('(1,129 curated words)')}
|
|
528
|
+
${c('rv search grace in rom')} Scoped to a book
|
|
529
|
+
${c('rv search "only begotten"')} Phrase match via FTS5
|
|
530
|
+
${c('rv search eternal life')} Multi-word AND match
|
|
531
|
+
${c('rv search grace --fts')} Force raw FTS5
|
|
532
|
+
${c('rv search grace --all')} Show all results ${d('(default: 20 per page)')}
|
|
533
|
+
|
|
534
|
+
${b('NAVIGATION')}
|
|
535
|
+
${c('rv')} Home screen — browse books, pick chapters
|
|
536
|
+
${c('rv continue')} Resume last-read chapter
|
|
537
|
+
|
|
538
|
+
${b('COPY')} ${d('add --copy to any read command')}
|
|
539
|
+
${c('rv john 3:16 --copy')} Copy with reference prefix
|
|
540
|
+
${c('rv john 3:16 --copy --no-ref')} Plain text only
|
|
541
|
+
${c('rv john 3:16 --copy --numbered')} Verse number prefix
|
|
542
|
+
${c('rv john 3:16 --copy --md')} Markdown block quote
|
|
543
|
+
${c('rv search grace --copy')} Copy all search results
|
|
544
|
+
|
|
545
|
+
${b('PAGER SHORTCUTS')} ${d('chapter mode in terminal')}
|
|
546
|
+
${d('Scroll')} ↑↓ or j/k line · Space/b page · g top · G bottom
|
|
547
|
+
${d('Chapter')} ←→ or n/p next/prev ${d('(cross-book)')}
|
|
548
|
+
${d('Toggles')} f footnotes · o outline
|
|
549
|
+
${d('Navigate')} : goto ${d('(type ref, e.g. rom 8)')} · H home · [/] back/fwd
|
|
550
|
+
${d('Study')} d study mode — browse footnotes + follow cross-references
|
|
551
|
+
${d('Copy')} c copy mode — select verses with cursor
|
|
552
|
+
${d('Search')} / find in chapter · n/N cycle matches
|
|
553
|
+
${d('Quit')} q or Esc
|
|
554
|
+
|
|
555
|
+
${b('STUDY MODE')} ${d('press d in pager')}
|
|
556
|
+
↑↓ move verse cursor · type # to follow cross-ref
|
|
557
|
+
v start range · c copy verse/range · ←→ chapter
|
|
558
|
+
: goto · [/] back/fwd · d exit
|
|
559
|
+
`);
|
|
560
|
+
});
|
|
561
|
+
program.parseAsync();
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type VerseSpec = {
|
|
2
|
+
type: 'single';
|
|
3
|
+
verse: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: 'range';
|
|
6
|
+
start: string;
|
|
7
|
+
end: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: 'list';
|
|
10
|
+
verses: string[];
|
|
11
|
+
};
|
|
12
|
+
export interface ParsedRef {
|
|
13
|
+
book: string;
|
|
14
|
+
chapter: number;
|
|
15
|
+
verses?: VerseSpec;
|
|
16
|
+
}
|
|
17
|
+
export declare function resolveBook(input: string): string | null;
|
|
18
|
+
export declare function parseRef(input: string): ParsedRef;
|
|
19
|
+
export declare function parseRefTokens(tokens: string[]): ParsedRef;
|
|
20
|
+
export declare function parseRefList(tokens: string[]): ParsedRef[];
|