rv-bible-cli 0.1.7 → 0.1.8
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 +1 -0
- package/dist/ui/Pager.js +403 -73
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,6 +123,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
|
|
|
123
123
|
| `d` | enter study mode |
|
|
124
124
|
| `c` | enter copy mode |
|
|
125
125
|
| `/` | find text in current chapter |
|
|
126
|
+
| `?` | search the whole Bible — type a query, browse results, Enter opens |
|
|
126
127
|
| `:` | jump to any book — type `rom 8`, `jn 3`, etc. |
|
|
127
128
|
| `[` / `]` | go back / forward (like browser history) |
|
|
128
129
|
| `H` | go to home screen |
|
package/dist/ui/Pager.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
|
3
3
|
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import clipboard from 'clipboardy';
|
|
6
|
-
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
|
|
6
|
+
import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from '../db.js';
|
|
7
7
|
import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom, getMargin } from '../format.js';
|
|
8
8
|
import { saveLastRead, getLastRead } from '../state.js';
|
|
9
9
|
import { resolveBook } from '../parser.js';
|
|
@@ -139,11 +139,17 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
139
139
|
// Copy mode state
|
|
140
140
|
const [cursorIdx, setCursorIdx] = useState(0);
|
|
141
141
|
const [selStart, setSelStart] = useState(null);
|
|
142
|
-
//
|
|
143
|
-
const [
|
|
144
|
-
const [
|
|
145
|
-
const [
|
|
146
|
-
const [
|
|
142
|
+
// Find-in-chapter mode state (the `/` modal)
|
|
143
|
+
const [findQuery, setFindQuery] = useState('');
|
|
144
|
+
const [findSubmitted, setFindSubmitted] = useState(false);
|
|
145
|
+
const [findMatches, setFindMatches] = useState([]);
|
|
146
|
+
const [findIdx, setFindIdx] = useState(0);
|
|
147
|
+
// Global search session state (the `?` modal — full Bible search)
|
|
148
|
+
const [gsearchInput, setGsearchInput] = useState('');
|
|
149
|
+
const [gsearchSession, setGsearchSession] = useState(null);
|
|
150
|
+
// Holds the search terms to highlight in the next-rendered chapter (consumed by
|
|
151
|
+
// a useEffect once `lines` rebuilds). Array so multi-term queries highlight all words.
|
|
152
|
+
const pendingFindTermsRef = useRef(null);
|
|
147
153
|
// Goto mode state
|
|
148
154
|
const [gotoInput, setGotoInput] = useState('');
|
|
149
155
|
// Navigation history: back/forward stacks (refs so useInput always sees latest)
|
|
@@ -328,38 +334,69 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
328
334
|
setScrollOffset(target);
|
|
329
335
|
}, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
|
|
330
336
|
// ── Search highlights ────────────────────────────────────────────────────
|
|
337
|
+
// Highlights every space-separated token in findQuery (so multi-term gsearch
|
|
338
|
+
// results like "only begotten" light up both words). For pure single-word find,
|
|
339
|
+
// this collapses to the original single-term behavior.
|
|
331
340
|
const displayLines = useMemo(() => {
|
|
332
|
-
if (!
|
|
341
|
+
if (!findSubmitted || findMatches.length === 0)
|
|
333
342
|
return lines;
|
|
334
|
-
const
|
|
343
|
+
const queryTerms = findQuery.split(/\s+/).filter(Boolean);
|
|
344
|
+
if (queryTerms.length === 0)
|
|
345
|
+
return lines;
|
|
346
|
+
const lowers = queryTerms.map(t => t.toLowerCase());
|
|
335
347
|
return lines.map((line) => {
|
|
336
348
|
const stripped = stripAnsi(line);
|
|
337
|
-
|
|
349
|
+
const lower = stripped.toLowerCase();
|
|
350
|
+
if (!lowers.some(l => lower.includes(l)))
|
|
338
351
|
return line;
|
|
339
|
-
return highlightTerms(stripped,
|
|
352
|
+
return highlightTerms(stripped, queryTerms);
|
|
340
353
|
});
|
|
341
|
-
}, [lines,
|
|
354
|
+
}, [lines, findSubmitted, findMatches, findQuery]);
|
|
342
355
|
// ── Navigation helpers ───────────────────────────────────────────────────
|
|
343
356
|
const restoreMode = useCallback(() => {
|
|
344
357
|
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
358
|
+
setFindSubmitted(false);
|
|
359
|
+
setFindQuery('');
|
|
360
|
+
setFindMatches([]);
|
|
348
361
|
setStudyRefInput('');
|
|
349
362
|
setStudyCursorIdx(0);
|
|
350
363
|
}, []);
|
|
351
364
|
const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
|
|
352
365
|
const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
|
|
353
|
-
// Same-chapter
|
|
354
|
-
//
|
|
366
|
+
// Same-chapter target: jump cursor (study) or scroll (normal) directly without
|
|
367
|
+
// re-rendering the chapter. Exits any modal mode (gsearch-list, etc.) via restoreMode.
|
|
355
368
|
if (sameChapter && pendingVerseRef.current) {
|
|
356
369
|
const target = pendingVerseRef.current;
|
|
357
370
|
pendingVerseRef.current = null;
|
|
358
371
|
let idx = verseMap.findIndex(e => e.verse === target);
|
|
359
372
|
if (idx < 0)
|
|
360
373
|
idx = verseMap.findIndex(e => e.verse.startsWith(target));
|
|
361
|
-
if (idx >= 0)
|
|
362
|
-
|
|
374
|
+
if (idx >= 0) {
|
|
375
|
+
const entry = verseMap[idx];
|
|
376
|
+
if (studyActiveRef.current) {
|
|
377
|
+
setStudyCursorIdx(idx);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
setScrollOffset(Math.max(0, entry.startLine - 2));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Highlight pending terms in current chapter (no new `lines` to trigger the effect).
|
|
384
|
+
const terms = pendingFindTermsRef.current;
|
|
385
|
+
if (terms && terms.length > 0) {
|
|
386
|
+
pendingFindTermsRef.current = null;
|
|
387
|
+
const lowers = terms.map(t => t.toLowerCase());
|
|
388
|
+
const matches = [];
|
|
389
|
+
for (let i = 0; i < lines.length; i++) {
|
|
390
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
391
|
+
if (lowers.some(l => lower.includes(l)))
|
|
392
|
+
matches.push(i);
|
|
393
|
+
}
|
|
394
|
+
setFindQuery(terms.join(' '));
|
|
395
|
+
setFindMatches(matches);
|
|
396
|
+
setFindSubmitted(true);
|
|
397
|
+
setFindIdx(0);
|
|
398
|
+
}
|
|
399
|
+
setMode(studyActiveRef.current ? 'study' : 'normal');
|
|
363
400
|
saveLastRead(newBook, newChapter);
|
|
364
401
|
return;
|
|
365
402
|
}
|
|
@@ -372,7 +409,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
372
409
|
setScrollOffset(0);
|
|
373
410
|
restoreMode();
|
|
374
411
|
saveLastRead(newBook, newChapter);
|
|
375
|
-
}, [restoreMode, verseMap]);
|
|
412
|
+
}, [restoreMode, verseMap, lines]);
|
|
376
413
|
const goBack = useCallback(() => {
|
|
377
414
|
const hist = historyRef.current;
|
|
378
415
|
if (hist.length === 0)
|
|
@@ -440,35 +477,149 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
440
477
|
setSelStart(null);
|
|
441
478
|
}, [selStart, cursorIdx, verses, bookName, chapter]);
|
|
442
479
|
// ── Search helpers ───────────────────────────────────────────────────────
|
|
443
|
-
const
|
|
444
|
-
if (!
|
|
480
|
+
const submitFind = useCallback(() => {
|
|
481
|
+
if (!findQuery)
|
|
482
|
+
return;
|
|
483
|
+
const queryTerms = findQuery.split(/\s+/).filter(Boolean);
|
|
484
|
+
if (queryTerms.length === 0)
|
|
445
485
|
return;
|
|
446
|
-
const
|
|
486
|
+
const lowers = queryTerms.map(t => t.toLowerCase());
|
|
447
487
|
const matches = [];
|
|
448
488
|
for (let i = 0; i < lines.length; i++) {
|
|
449
|
-
|
|
489
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
490
|
+
if (lowers.some(l => lower.includes(l)))
|
|
450
491
|
matches.push(i);
|
|
451
492
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
493
|
+
setFindMatches(matches);
|
|
494
|
+
setFindSubmitted(true);
|
|
495
|
+
setFindIdx(0);
|
|
455
496
|
if (matches.length > 0)
|
|
456
497
|
scrollTo(matches[0]);
|
|
457
|
-
}, [
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
498
|
+
}, [findQuery, lines, scrollTo]);
|
|
499
|
+
const nextFindMatch = useCallback(() => {
|
|
500
|
+
if (findMatches.length === 0)
|
|
460
501
|
return;
|
|
461
|
-
const next = (
|
|
462
|
-
|
|
463
|
-
scrollTo(
|
|
464
|
-
}, [
|
|
465
|
-
const
|
|
466
|
-
if (
|
|
502
|
+
const next = (findIdx + 1) % findMatches.length;
|
|
503
|
+
setFindIdx(next);
|
|
504
|
+
scrollTo(findMatches[next]);
|
|
505
|
+
}, [findIdx, findMatches, scrollTo]);
|
|
506
|
+
const prevFindMatch = useCallback(() => {
|
|
507
|
+
if (findMatches.length === 0)
|
|
467
508
|
return;
|
|
468
|
-
const prev = (
|
|
469
|
-
|
|
470
|
-
scrollTo(
|
|
471
|
-
}, [
|
|
509
|
+
const prev = (findIdx - 1 + findMatches.length) % findMatches.length;
|
|
510
|
+
setFindIdx(prev);
|
|
511
|
+
scrollTo(findMatches[prev]);
|
|
512
|
+
}, [findIdx, findMatches, scrollTo]);
|
|
513
|
+
// ── Global search helpers (the `?` modal — full Bible search) ─────────────
|
|
514
|
+
// Page size for paginating results in the gsearch-list view (←/→ to page).
|
|
515
|
+
const GSEARCH_PAGE_SIZE = 100;
|
|
516
|
+
// Parse "in <book>" scope from tokens (mirrors CLI parseScope behavior)
|
|
517
|
+
const parseGsearchScope = useCallback((tokens) => {
|
|
518
|
+
for (let nameLen = 3; nameLen >= 1; nameLen--) {
|
|
519
|
+
const inIdx = tokens.length - nameLen - 1;
|
|
520
|
+
if (inIdx < 1)
|
|
521
|
+
continue;
|
|
522
|
+
if (tokens[inIdx]?.toLowerCase() !== 'in')
|
|
523
|
+
continue;
|
|
524
|
+
const resolved = resolveBook(tokens.slice(inIdx + 1, inIdx + 1 + nameLen).join(' '));
|
|
525
|
+
if (resolved)
|
|
526
|
+
return { queryTokens: tokens.slice(0, inIdx), book: resolved };
|
|
527
|
+
}
|
|
528
|
+
return { queryTokens: tokens, book: undefined };
|
|
529
|
+
}, []);
|
|
530
|
+
// Run the search. Single known concordance word → concordance, else FTS LIKE.
|
|
531
|
+
// Returns ALL results — pagination happens at render time (GSEARCH_PAGE_SIZE per page).
|
|
532
|
+
const executeGsearch = useCallback((rawInput) => {
|
|
533
|
+
const trimmed = rawInput.trim();
|
|
534
|
+
if (!trimmed)
|
|
535
|
+
return null;
|
|
536
|
+
const tokens = trimmed.split(/\s+/);
|
|
537
|
+
const { queryTokens, book: scopeBook } = parseGsearchScope(tokens);
|
|
538
|
+
if (queryTokens.length === 0)
|
|
539
|
+
return null;
|
|
540
|
+
const word = queryTokens.join(' ');
|
|
541
|
+
let results;
|
|
542
|
+
let terms;
|
|
543
|
+
if (queryTokens.length === 1 && isInConcordance(word)) {
|
|
544
|
+
results = getTopicVerses(word, scopeBook);
|
|
545
|
+
terms = [word];
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const ftsQuery = queryTokens.length === 1
|
|
549
|
+
? `"${queryTokens[0].replace(/"/g, '')}"`
|
|
550
|
+
: queryTokens.map(t => `"${t.replace(/"/g, '')}"`).join(' AND ');
|
|
551
|
+
results = searchFTS(ftsQuery, scopeBook);
|
|
552
|
+
terms = queryTokens.map(t => t.replace(/"/g, '').trim()).filter(Boolean);
|
|
553
|
+
}
|
|
554
|
+
return { query: trimmed, results, terms, cursor: 0, pageIdx: 0 };
|
|
555
|
+
}, [parseGsearchScope]);
|
|
556
|
+
// Build the rendered result list lines + per-result line ranges (for cursor scroll).
|
|
557
|
+
// Renders only the current page (GSEARCH_PAGE_SIZE results); the cursor index in
|
|
558
|
+
// the session is page-local, so itemMap aligns directly.
|
|
559
|
+
const gsearchListData = useMemo(() => {
|
|
560
|
+
if (!gsearchSession)
|
|
561
|
+
return null;
|
|
562
|
+
const { results, terms, cursor, pageIdx } = gsearchSession;
|
|
563
|
+
const start = pageIdx * GSEARCH_PAGE_SIZE;
|
|
564
|
+
const pageResults = results.slice(start, start + GSEARCH_PAGE_SIZE);
|
|
565
|
+
const renderLines = [];
|
|
566
|
+
const itemMap = [];
|
|
567
|
+
const wrapWidth = Math.max(40, Math.min(cols - 10, 90));
|
|
568
|
+
pageResults.forEach((v, i) => {
|
|
569
|
+
const startLine = renderLines.length;
|
|
570
|
+
const bn = getBookInfo(v.book)?.full_name ?? v.book;
|
|
571
|
+
const ref = `${bn} ${v.chapter}:${v.verse}`;
|
|
572
|
+
const cleanText = stripMarkers(v.text);
|
|
573
|
+
const highlighted = highlightTerms(cleanText, terms);
|
|
574
|
+
const wrapped = wrapWords(highlighted, wrapWidth, true);
|
|
575
|
+
const mark = i === cursor ? chalk.magenta('›') : ' ';
|
|
576
|
+
renderLines.push(` ${mark} ${chalk.bold(ref)}`);
|
|
577
|
+
for (const wl of wrapped)
|
|
578
|
+
renderLines.push(` ${wl}`);
|
|
579
|
+
const endLine = renderLines.length - 1;
|
|
580
|
+
renderLines.push(''); // blank separator
|
|
581
|
+
itemMap.push({ startLine, endLine });
|
|
582
|
+
});
|
|
583
|
+
return { lines: renderLines, itemMap, pageResults };
|
|
584
|
+
}, [gsearchSession, cols]);
|
|
585
|
+
// Center the cursor result in the list viewport (same pattern as study mode).
|
|
586
|
+
const gsearchScroll = useMemo(() => {
|
|
587
|
+
if (mode !== 'gsearch-list' || !gsearchSession || !gsearchListData)
|
|
588
|
+
return 0;
|
|
589
|
+
const item = gsearchListData.itemMap[gsearchSession.cursor];
|
|
590
|
+
if (!item)
|
|
591
|
+
return 0;
|
|
592
|
+
const itemH = item.endLine - item.startLine + 1;
|
|
593
|
+
let target;
|
|
594
|
+
if (itemH >= viewportH) {
|
|
595
|
+
target = item.startLine;
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
const itemMid = item.startLine + Math.floor(itemH / 2);
|
|
599
|
+
target = itemMid - Math.floor(viewportH / 2);
|
|
600
|
+
}
|
|
601
|
+
return Math.max(0, Math.min(target, Math.max(0, gsearchListData.lines.length - 1)));
|
|
602
|
+
}, [mode, gsearchSession, gsearchListData, viewportH]);
|
|
603
|
+
// After navigate from a search result, highlight ALL search terms in the new chapter
|
|
604
|
+
// (piggybacks on the chapter-find code path so n/N cycling works too). Doesn't
|
|
605
|
+
// scroll — the pendingVerseRef effect already positioned the cursor on the picked verse.
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
const terms = pendingFindTermsRef.current;
|
|
608
|
+
if (!terms || terms.length === 0 || lines.length === 0)
|
|
609
|
+
return;
|
|
610
|
+
pendingFindTermsRef.current = null;
|
|
611
|
+
const lowers = terms.map(t => t.toLowerCase());
|
|
612
|
+
const matches = [];
|
|
613
|
+
for (let i = 0; i < lines.length; i++) {
|
|
614
|
+
const lower = stripAnsi(lines[i]).toLowerCase();
|
|
615
|
+
if (lowers.some(l => lower.includes(l)))
|
|
616
|
+
matches.push(i);
|
|
617
|
+
}
|
|
618
|
+
setFindQuery(terms.join(' '));
|
|
619
|
+
setFindMatches(matches);
|
|
620
|
+
setFindSubmitted(true);
|
|
621
|
+
setFindIdx(0);
|
|
622
|
+
}, [lines]);
|
|
472
623
|
// ── Goto helpers ─────────────────────────────────────────────────────────
|
|
473
624
|
const submitGoto = useCallback(() => {
|
|
474
625
|
const trimmed = gotoInput.trim();
|
|
@@ -557,8 +708,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
557
708
|
if (matches.length > 0) {
|
|
558
709
|
if (lower === lastLetterRef.current) {
|
|
559
710
|
// Same letter again: cycle to next match after current position
|
|
560
|
-
const
|
|
561
|
-
setHomeIdx(
|
|
711
|
+
const nextLetterMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
|
|
712
|
+
setHomeIdx(nextLetterMatch.i);
|
|
562
713
|
}
|
|
563
714
|
else {
|
|
564
715
|
setHomeIdx(matches[0].i);
|
|
@@ -608,35 +759,145 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
608
759
|
}
|
|
609
760
|
return;
|
|
610
761
|
}
|
|
611
|
-
// ──
|
|
612
|
-
if (mode === '
|
|
762
|
+
// ── Find-in-chapter mode (`/` modal) ────────────────────────────────
|
|
763
|
+
if (mode === 'find') {
|
|
613
764
|
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
614
765
|
if (key.escape) {
|
|
615
766
|
setMode(returnMode);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
767
|
+
setFindQuery('');
|
|
768
|
+
setFindSubmitted(false);
|
|
769
|
+
setFindMatches([]);
|
|
619
770
|
return;
|
|
620
771
|
}
|
|
621
772
|
if (key.return) {
|
|
622
|
-
|
|
773
|
+
submitFind();
|
|
623
774
|
setMode(returnMode);
|
|
624
775
|
return;
|
|
625
776
|
}
|
|
626
777
|
if (key.backspace || key.delete) {
|
|
627
|
-
|
|
778
|
+
setFindQuery(prev => prev.slice(0, -1));
|
|
628
779
|
return;
|
|
629
780
|
}
|
|
630
|
-
if (
|
|
631
|
-
|
|
781
|
+
if (findSubmitted && input === 'n') {
|
|
782
|
+
nextFindMatch();
|
|
632
783
|
return;
|
|
633
784
|
}
|
|
634
|
-
if (
|
|
635
|
-
|
|
785
|
+
if (findSubmitted && input === 'N') {
|
|
786
|
+
prevFindMatch();
|
|
636
787
|
return;
|
|
637
788
|
}
|
|
638
789
|
if (input && !key.ctrl && !key.meta) {
|
|
639
|
-
|
|
790
|
+
setFindQuery(prev => prev + input);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
// ── Global-search input mode (`?` modal — type query) ───────────────
|
|
795
|
+
if (mode === 'gsearch-input') {
|
|
796
|
+
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
797
|
+
if (key.escape) {
|
|
798
|
+
setMode(returnMode);
|
|
799
|
+
setGsearchInput('');
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (key.return) {
|
|
803
|
+
const trimmed = gsearchInput.trim();
|
|
804
|
+
if (!trimmed)
|
|
805
|
+
return;
|
|
806
|
+
// Same query as the live session → restore the cached list at last cursor.
|
|
807
|
+
if (gsearchSession && gsearchSession.query === trimmed) {
|
|
808
|
+
setMode('gsearch-list');
|
|
809
|
+
setGsearchInput('');
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const session = executeGsearch(gsearchInput);
|
|
813
|
+
if (session) {
|
|
814
|
+
setGsearchSession(session);
|
|
815
|
+
setMode('gsearch-list');
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
// Empty results case still gets a session so the list shows "0 results".
|
|
819
|
+
setGsearchSession({ query: trimmed, results: [], terms: trimmed.split(/\s+/), cursor: 0, pageIdx: 0 });
|
|
820
|
+
setMode('gsearch-list');
|
|
821
|
+
}
|
|
822
|
+
setGsearchInput('');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (key.backspace || key.delete) {
|
|
826
|
+
setGsearchInput(prev => prev.slice(0, -1));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (input && !key.ctrl && !key.meta) {
|
|
830
|
+
setGsearchInput(prev => prev + input);
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// ── Global-search list mode (`?` modal — browse results) ────────────
|
|
835
|
+
if (mode === 'gsearch-list') {
|
|
836
|
+
if (!gsearchSession) {
|
|
837
|
+
setMode('normal');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const totalResults = gsearchSession.results.length;
|
|
841
|
+
const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
|
|
842
|
+
const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
|
|
843
|
+
const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
|
|
844
|
+
const pageLen = pageEnd - pageStart;
|
|
845
|
+
const returnMode = studyActiveRef.current ? 'study' : 'normal';
|
|
846
|
+
if (key.escape || input === 'q') {
|
|
847
|
+
setMode(returnMode);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (input === '?') {
|
|
851
|
+
setMode('gsearch-input');
|
|
852
|
+
setGsearchInput(gsearchSession.query);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (key.return) {
|
|
856
|
+
const v = gsearchSession.results[pageStart + gsearchSession.cursor];
|
|
857
|
+
if (v) {
|
|
858
|
+
pendingVerseRef.current = v.verse;
|
|
859
|
+
pendingFindTermsRef.current = gsearchSession.terms.length > 0 ? gsearchSession.terms : null;
|
|
860
|
+
// navigate() calls restoreMode() which puts us back in normal/study.
|
|
861
|
+
navigate(v.book, v.chapter);
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (totalResults === 0)
|
|
866
|
+
return;
|
|
867
|
+
// Page navigation: ← prev page, → next page (cursor resets to top of new page).
|
|
868
|
+
if (key.leftArrow || input === 'h') {
|
|
869
|
+
if (gsearchSession.pageIdx > 0) {
|
|
870
|
+
setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx - 1, cursor: 0 } : s);
|
|
871
|
+
}
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (key.rightArrow || input === 'l') {
|
|
875
|
+
if (gsearchSession.pageIdx < pageCount - 1) {
|
|
876
|
+
setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx + 1, cursor: 0 } : s);
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (key.upArrow || input === 'k') {
|
|
881
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, s.cursor - 1) } : s);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (key.downArrow || input === 'j') {
|
|
885
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.min(pageLen - 1, s.cursor + 1) } : s);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (input === 'g') {
|
|
889
|
+
setGsearchSession(s => s ? { ...s, cursor: 0 } : s);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (input === 'G') {
|
|
893
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, pageLen - 1) } : s);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (key.tab) {
|
|
897
|
+
const step = Math.max(1, Math.floor(pageLen / 4));
|
|
898
|
+
const dir = key.shift ? -1 : 1;
|
|
899
|
+
setGsearchSession(s => s ? { ...s, cursor: Math.max(0, Math.min(pageLen - 1, s.cursor + dir * step)) } : s);
|
|
900
|
+
return;
|
|
640
901
|
}
|
|
641
902
|
return;
|
|
642
903
|
}
|
|
@@ -802,12 +1063,18 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
802
1063
|
setGotoInput('');
|
|
803
1064
|
return;
|
|
804
1065
|
}
|
|
805
|
-
//
|
|
1066
|
+
// Find within chapter
|
|
806
1067
|
if (input === '/') {
|
|
807
|
-
setMode('
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1068
|
+
setMode('find');
|
|
1069
|
+
setFindQuery('');
|
|
1070
|
+
setFindSubmitted(false);
|
|
1071
|
+
setFindMatches([]);
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
// Global search (full Bible)
|
|
1075
|
+
if (input === '?') {
|
|
1076
|
+
setMode('gsearch-input');
|
|
1077
|
+
setGsearchInput(gsearchSession?.query ?? '');
|
|
811
1078
|
return;
|
|
812
1079
|
}
|
|
813
1080
|
return;
|
|
@@ -889,8 +1156,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
889
1156
|
return;
|
|
890
1157
|
}
|
|
891
1158
|
if (input === 'n' || key.rightArrow) {
|
|
892
|
-
if (
|
|
893
|
-
|
|
1159
|
+
if (findSubmitted && findMatches.length > 0) {
|
|
1160
|
+
nextFindMatch();
|
|
894
1161
|
}
|
|
895
1162
|
else {
|
|
896
1163
|
goNext();
|
|
@@ -898,7 +1165,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
898
1165
|
return;
|
|
899
1166
|
}
|
|
900
1167
|
if (input === 'N') {
|
|
901
|
-
|
|
1168
|
+
prevFindMatch();
|
|
902
1169
|
return;
|
|
903
1170
|
}
|
|
904
1171
|
if (input === 'p' || key.leftArrow) {
|
|
@@ -914,10 +1181,15 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
914
1181
|
return;
|
|
915
1182
|
}
|
|
916
1183
|
if (input === '/') {
|
|
917
|
-
setMode('
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1184
|
+
setMode('find');
|
|
1185
|
+
setFindQuery('');
|
|
1186
|
+
setFindSubmitted(false);
|
|
1187
|
+
setFindMatches([]);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (input === '?') {
|
|
1191
|
+
setMode('gsearch-input');
|
|
1192
|
+
setGsearchInput(gsearchSession?.query ?? '');
|
|
921
1193
|
return;
|
|
922
1194
|
}
|
|
923
1195
|
if (input === 'c') {
|
|
@@ -1085,7 +1357,31 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1085
1357
|
}
|
|
1086
1358
|
return line;
|
|
1087
1359
|
}) : visibleSlice;
|
|
1088
|
-
|
|
1360
|
+
// Global-search list view replaces the chapter content slot.
|
|
1361
|
+
let content;
|
|
1362
|
+
if (mode === 'gsearch-list' && gsearchSession && gsearchListData) {
|
|
1363
|
+
const slice = gsearchListData.lines.slice(gsearchScroll, gsearchScroll + viewportH);
|
|
1364
|
+
while (slice.length < viewportH)
|
|
1365
|
+
slice.push('');
|
|
1366
|
+
if (gsearchSession.results.length === 0) {
|
|
1367
|
+
// Empty-state: center "no results" text in the viewport
|
|
1368
|
+
const msg = chalk.dim(` No results for "${gsearchSession.query}"`);
|
|
1369
|
+
const padTop = Math.max(0, Math.floor(viewportH / 2) - 1);
|
|
1370
|
+
const out = [];
|
|
1371
|
+
for (let i = 0; i < padTop; i++)
|
|
1372
|
+
out.push('');
|
|
1373
|
+
out.push(msg);
|
|
1374
|
+
while (out.length < viewportH)
|
|
1375
|
+
out.push('');
|
|
1376
|
+
content = out.join('\n');
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
content = slice.join('\n');
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
content = contentLines.join('\n');
|
|
1384
|
+
}
|
|
1089
1385
|
// ── Header ───────────────────────────────────────────────────────────────
|
|
1090
1386
|
const CATEGORY_LABELS = {
|
|
1091
1387
|
Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
|
|
@@ -1105,7 +1401,24 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1105
1401
|
? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}${zoomLabel}`
|
|
1106
1402
|
: ` ${title}${zoomLabel}`;
|
|
1107
1403
|
const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
|
|
1108
|
-
|
|
1404
|
+
let header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
|
|
1405
|
+
// Override header for the global-search list view
|
|
1406
|
+
if (mode === 'gsearch-list' && gsearchSession) {
|
|
1407
|
+
const totalResults = gsearchSession.results.length;
|
|
1408
|
+
const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
|
|
1409
|
+
const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
|
|
1410
|
+
const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
|
|
1411
|
+
const pageLen = pageEnd - pageStart;
|
|
1412
|
+
const countLabel = totalResults === 0
|
|
1413
|
+
? 'no results'
|
|
1414
|
+
: pageCount > 1
|
|
1415
|
+
? `page ${gsearchSession.pageIdx + 1}/${pageCount} · ${pageStart + 1}–${pageEnd} of ${totalResults}`
|
|
1416
|
+
: `${totalResults} result${totalResults === 1 ? '' : 's'}`;
|
|
1417
|
+
const gsTitleLeft = ` ${chalk.bold(`Search: "${gsearchSession.query}"`)} ${chalk.dim('·')} ${chalk.dim(countLabel)}`;
|
|
1418
|
+
const gsPosition = pageLen > 0 ? `${gsearchSession.cursor + 1}/${pageLen}` : '';
|
|
1419
|
+
const gsPad = Math.max(1, cols - stripAnsi(gsTitleLeft).length - gsPosition.length - 2);
|
|
1420
|
+
header = `${gsTitleLeft}${' '.repeat(gsPad)}${chalk.dim(gsPosition)}`;
|
|
1421
|
+
}
|
|
1109
1422
|
// ── Footer (two lines for normal/study, one line for modal modes) ──────
|
|
1110
1423
|
// Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
|
|
1111
1424
|
const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
|
|
@@ -1114,20 +1427,35 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1114
1427
|
if (flash) {
|
|
1115
1428
|
footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
|
|
1116
1429
|
}
|
|
1117
|
-
else if (mode === '
|
|
1118
|
-
if (
|
|
1119
|
-
const countLabel =
|
|
1430
|
+
else if (mode === 'find') {
|
|
1431
|
+
if (findSubmitted) {
|
|
1432
|
+
const countLabel = findMatches.length === 0
|
|
1120
1433
|
? 'no matches'
|
|
1121
|
-
: `match ${
|
|
1122
|
-
footer1 = ` / ${
|
|
1434
|
+
: `match ${findIdx + 1}/${findMatches.length}`;
|
|
1435
|
+
footer1 = ` / ${findQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
|
|
1123
1436
|
}
|
|
1124
1437
|
else {
|
|
1125
|
-
footer1 = ` / ${
|
|
1438
|
+
footer1 = ` / ${findQuery}${chalk.dim('_')} ${[kb('Enter', 'find'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1126
1439
|
}
|
|
1127
1440
|
}
|
|
1128
1441
|
else if (mode === 'goto') {
|
|
1129
1442
|
footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1130
1443
|
}
|
|
1444
|
+
else if (mode === 'gsearch-input') {
|
|
1445
|
+
footer1 = ` ? ${gsearchInput}${chalk.dim('_')} ${chalk.dim('try grace · "only begotten" · grace in romans')}${FOOTER_SEP}${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
|
|
1446
|
+
}
|
|
1447
|
+
else if (mode === 'gsearch-list') {
|
|
1448
|
+
const showPageHint = gsearchSession ? gsearchSession.results.length > GSEARCH_PAGE_SIZE : false;
|
|
1449
|
+
const listKeys = [
|
|
1450
|
+
kb('↑↓', 'select'),
|
|
1451
|
+
kb('Tab/⇧Tab', 'quarter'),
|
|
1452
|
+
kb('g/G', 'first/last'),
|
|
1453
|
+
];
|
|
1454
|
+
if (showPageHint)
|
|
1455
|
+
listKeys.push(kb('←→', 'page'));
|
|
1456
|
+
listKeys.push(kb('Enter', 'open'), kb('?', 'edit'), kb('Esc', 'back'));
|
|
1457
|
+
footer1 = ` ${listKeys.join(FOOTER_SEP)}`;
|
|
1458
|
+
}
|
|
1131
1459
|
else if (mode === 'study') {
|
|
1132
1460
|
const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
|
|
1133
1461
|
const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
|
|
@@ -1138,6 +1466,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1138
1466
|
toggleHint(notes, 'f', 'notes'),
|
|
1139
1467
|
toggleHint(outline, 'o', 'outline'),
|
|
1140
1468
|
kb('/', 'find'),
|
|
1469
|
+
kb('?', 'search'),
|
|
1141
1470
|
].join(FOOTER_SEP)}`;
|
|
1142
1471
|
const f2Parts = [
|
|
1143
1472
|
kb('↑↓', 'verse'),
|
|
@@ -1168,6 +1497,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
|
|
|
1168
1497
|
kb('d', 'study'),
|
|
1169
1498
|
kb('c', 'copy'),
|
|
1170
1499
|
kb('/', 'find'),
|
|
1500
|
+
kb('?', 'search'),
|
|
1171
1501
|
].join(FOOTER_SEP)}`;
|
|
1172
1502
|
footer2 = ` ${[
|
|
1173
1503
|
kb('↑↓', 'scroll'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rv-bible-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Fast, offline terminal Bible reader for the Recovery Version. Footnotes, cross-references, concordance search, interactive pager.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|