rv-bible-cli 0.1.5 → 0.1.6

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 CHANGED
@@ -114,6 +114,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
114
114
  |-----|--------|
115
115
  | `j` / `k` / arrows | scroll up/down |
116
116
  | `Space` / `b` | page down/up |
117
+ | `Tab` / `Shift+Tab` | scroll down / up by a quarter of the chapter |
117
118
  | `g` / `G` | jump to top / bottom |
118
119
  | left / right | prev / next chapter (even across books) |
119
120
  | `f` | toggle footnotes on/off |
@@ -134,18 +135,22 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
134
135
  Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
135
136
 
136
137
  - A cursor highlights the current verse
137
- - The bottom panel shows all footnotes for that verse with numbered cross-references
138
+ - The bottom panel shows the **full** footnote text for that verse — header line with marker + word + numbered cross-references, then the wrapped commentary body underneath
138
139
  - **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
139
140
  - Press `[` to go back to where you were
140
141
  - Study mode stays active as you navigate — it's designed for chaining through references
142
+ - If a verse has very long notes, the panel caps at half your terminal height — use `,` / `.` to scroll within it
141
143
 
142
144
  | Key | Action |
143
145
  |-----|--------|
144
- | up / down | move between verses |
146
+ | up / down | move cursor up / down one verse |
145
147
  | left / right | prev / next chapter |
146
148
  | type number | follow a cross-reference |
147
149
  | `v` then up/down then `c` | select a range and copy |
148
150
  | `c` | copy current verse |
151
+ | `Tab` / `Shift+Tab` | jump cursor by a quarter of the chapter |
152
+ | `g` / `G` | cursor to first / last verse |
153
+ | `,` / `.` | scroll the footnote panel up / down (when it overflows) |
149
154
  | `f` / `o` | toggle footnotes / outline |
150
155
  | `/` | find text |
151
156
  | `:` | jump to a book |
package/dist/format.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare function getZoom(): number;
8
8
  export declare function zoomIn(): void;
9
9
  export declare function zoomOut(): void;
10
10
  export declare function zoomReset(): void;
11
+ export declare function wrapWords(text: string, maxWidth: number, ansi?: boolean): string[];
11
12
  export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
12
13
  export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
13
14
  export declare function renderNoteDisplay(verse: Verse, fn: Footnote, bookName: string): string;
package/dist/format.js CHANGED
@@ -118,7 +118,7 @@ function visibleLength(s) {
118
118
  // ── Word-wrap helper ─────────────────────────────────────────────────────────
119
119
  // Wraps plain `text` to `maxWidth` columns. Returns array of line strings.
120
120
  // Use visibleLength=true when words may contain ANSI escape codes.
121
- function wrapWords(text, maxWidth, ansi = false) {
121
+ export function wrapWords(text, maxWidth, ansi = false) {
122
122
  if (maxWidth < 20)
123
123
  return [text];
124
124
  const words = text.split(' ');
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { program } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import clipboard from 'clipboardy';
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { parseRefList, resolveBook } from './parser.js';
6
8
  import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getFootnote, getVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from './db.js';
7
9
  import { renderVerses, renderVerseInline, renderFootnoteBlock, renderNoteDisplay, renderNoteDisplayAll, stripMarkers, highlightTerms, } from './format.js';
@@ -10,10 +12,12 @@ import { getLastRead, saveLastRead } from './state.js';
10
12
  function stripAnsi(s) {
11
13
  return s.replace(/\x1b\[[0-9;]*m/g, '');
12
14
  }
15
+ // Read version from package.json (single source of truth — avoids drift)
16
+ const pkg = JSON.parse(readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf-8'));
13
17
  program
14
18
  .name('rv')
15
19
  .description('Recovery Version Bible CLI')
16
- .version('0.1.0')
20
+ .version(pkg.version)
17
21
  .enablePositionalOptions();
18
22
  // Formats a ParsedRef + book name into a display label: "John 3", "John 3:16", "John 3:16–18"
19
23
  function formatRefLabel(ref, bookName) {
package/dist/ui/Pager.js CHANGED
@@ -4,7 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
4
  import chalk from 'chalk';
5
5
  import clipboard from 'clipboardy';
6
6
  import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
7
- import { renderVerses, stripMarkers, highlightTerms, toSuperscript, zoomIn, zoomOut, zoomReset, getZoom } from '../format.js';
7
+ import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom } from '../format.js';
8
8
  import { saveLastRead, getLastRead } from '../state.js';
9
9
  import { resolveBook } from '../parser.js';
10
10
  import { getNextChapter, getPrevChapter } from './nav.js';
@@ -144,6 +144,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
144
144
  const [studyCursorIdx, setStudyCursorIdx] = useState(0);
145
145
  const [studySelStart, setStudySelStart] = useState(null);
146
146
  const [studyRefInput, setStudyRefInput] = useState(''); // multi-digit ref number
147
+ const [panelScrollOffset, setPanelScrollOffset] = useState(0); // scroll within study panel when notes overflow cap
147
148
  const studyActiveRef = useRef(false); // persists across navigate so study mode survives ref follows
148
149
  const pendingVerseRef = useRef(null); // target verse to jump to after navigate
149
150
  // Flash message
@@ -215,10 +216,13 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
215
216
  const panelLines = [];
216
217
  const verseLabel = `${bookName} ${chapter}:${v.verse}`;
217
218
  panelLines.push(` ${chalk.bold(verseLabel)} ${chalk.dim(`— ${fns.length} note${fns.length === 1 ? '' : 's'}`)}`);
219
+ const BODY_INDENT = ' '; // 5 spaces — visually under the word, after marker
220
+ const bodyWidth = Math.max(20, cols - BODY_INDENT.length);
218
221
  for (const fn of fns) {
219
222
  // Extract the word the marker attaches to (first word after "Book ch:v word" in footnote text)
220
- const fnTextParts = fn.text.split(/\s+/);
223
+ const fnTextParts = fn.text.trim().split(/\s+/);
221
224
  const word = fnTextParts[2] ?? ''; // "Joh 3:16 loved ..." → "loved"
225
+ const noteBody = fnTextParts.slice(3).join(' ').trim(); // everything after "Book ch:v word"
222
226
  const markerDisp = toSuperscript(fn.marker);
223
227
  const refs = refsByMarker.get(fn.marker) ?? [];
224
228
  // Deduplicate refs by target
@@ -230,42 +234,74 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
230
234
  seen.add(key);
231
235
  return true;
232
236
  });
233
- if (uniqueRefs.length === 0) {
234
- // Study note with no cross-refs — show truncated text
235
- const notePreview = fn.text.length > 60 ? fn.text.substring(0, 57) + '...' : fn.text;
236
- panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)} ${chalk.dim(notePreview)}`);
237
+ // Build numbered ref labels (mutates studyRefs)
238
+ const refLabels = [];
239
+ for (const r of uniqueRefs) {
240
+ const refNum = studyRefs.length + 1;
241
+ const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
242
+ const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
243
+ studyRefs.push({
244
+ num: refNum, marker: fn.marker,
245
+ tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
246
+ });
247
+ refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
237
248
  }
238
- else {
239
- // Build numbered ref labels
240
- const refLabels = [];
241
- for (const r of uniqueRefs) {
242
- const refNum = studyRefs.length + 1;
243
- const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
244
- const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
245
- studyRefs.push({
246
- num: refNum, marker: fn.marker,
247
- tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
248
- });
249
- refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
249
+ // Header line: marker + word + refs (refs may be empty)
250
+ const refsPart = refLabels.length > 0 ? ` ${refLabels.join(' ')}` : '';
251
+ panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${refsPart}`);
252
+ // Body: wrap full note text under hanging indent, dim style. No truncation.
253
+ if (noteBody) {
254
+ const wrapped = wrapWords(noteBody, bodyWidth);
255
+ for (const wl of wrapped) {
256
+ panelLines.push(`${BODY_INDENT}${chalk.dim(wl)}`);
250
257
  }
251
- // Compact: marker + word + refs on one line (wrap if needed)
252
- const hasStudyNote = fn.text.length > 80;
253
- const noteHint = hasStudyNote ? chalk.dim(' (note)') : '';
254
- panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${noteHint} ${refLabels.join(' ')}`);
255
258
  }
256
259
  }
257
260
  return { lines: panelLines, refs: studyRefs };
258
- }, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName]);
261
+ }, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName, cols]);
262
+ // ── Study panel sizing & scroll (cap at 50% of screen, scrollable on overflow) ──
263
+ const panelLinesAll = studyPanel.lines;
264
+ const panelMaxH = Math.max(3, Math.floor(rows * 0.5));
265
+ const panelOverflow = panelLinesAll.length > panelMaxH;
266
+ const panelContentSlots = panelOverflow ? panelMaxH - 1 : panelLinesAll.length; // reserve 1 line for indicator
267
+ const maxPanelScroll = Math.max(0, panelLinesAll.length - panelContentSlots);
268
+ const panelScrollClamped = Math.max(0, Math.min(panelScrollOffset, maxPanelScroll));
269
+ const panelVisibleLines = panelLinesAll.slice(panelScrollClamped, panelScrollClamped + panelContentSlots);
270
+ const panelDisplayH = panelLinesAll.length > 0 ? panelVisibleLines.length + (panelOverflow ? 1 : 0) : 0;
259
271
  // ── Viewport height (shrinks when study panel is visible) ────────────
260
- const studyPanelH = studyPanel.lines.length > 0 ? studyPanel.lines.length + 1 : 0; // +1 for separator
272
+ const studyPanelH = panelDisplayH > 0 ? panelDisplayH + 1 : 0; // +1 for separator above panel
261
273
  const hasFooter2 = mode === 'normal' || mode === 'study';
262
274
  const chromeH = 3 + (hasFooter2 ? 2 : 1) + studyPanelH; // header + sep + footer(s) + sep + panel
263
275
  const viewportH = Math.max(1, rows - chromeH);
276
+ // Reset panel scroll when the verse cursor changes or when leaving study mode
277
+ useEffect(() => {
278
+ setPanelScrollOffset(0);
279
+ }, [studyCursorIdx, book, chapter, mode]);
264
280
  // Clamp scroll when content or viewport changes
265
281
  const maxScroll = Math.max(0, lines.length - viewportH);
266
282
  useEffect(() => {
267
283
  setScrollOffset(prev => Math.min(prev, maxScroll));
268
284
  }, [maxScroll]);
285
+ // Keep the study-mode cursor verse fully visible above the footnote panel.
286
+ // Triggers when cursor moves OR viewport shrinks (panel grew with longer notes).
287
+ useEffect(() => {
288
+ if (mode !== 'study' || verseMap.length === 0)
289
+ return;
290
+ const entry = verseMap[studyCursorIdx];
291
+ if (!entry)
292
+ return;
293
+ setScrollOffset(prev => {
294
+ // Cursor verse extends below the visible viewport — pull it in from the bottom
295
+ if (entry.endLine >= prev + viewportH) {
296
+ return Math.max(0, entry.endLine - viewportH + 1);
297
+ }
298
+ // Cursor verse starts above the visible viewport — pull it in from the top
299
+ if (entry.startLine < prev) {
300
+ return entry.startLine;
301
+ }
302
+ return prev;
303
+ });
304
+ }, [studyCursorIdx, viewportH, mode, verseMap]);
269
305
  // ── Search highlights ────────────────────────────────────────────────────
270
306
  const displayLines = useMemo(() => {
271
307
  if (!searchSubmitted || searchMatches.length === 0)
@@ -288,6 +324,20 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
288
324
  setStudyCursorIdx(0);
289
325
  }, []);
290
326
  const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
327
+ const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
328
+ // Same-chapter cross-ref follow: jump cursor directly. Don't reset state via
329
+ // restoreMode (would clobber cursor to 0), don't push to history (cursor isn't tracked there).
330
+ if (sameChapter && pendingVerseRef.current) {
331
+ const target = pendingVerseRef.current;
332
+ pendingVerseRef.current = null;
333
+ let idx = verseMap.findIndex(e => e.verse === target);
334
+ if (idx < 0)
335
+ idx = verseMap.findIndex(e => e.verse.startsWith(target));
336
+ if (idx >= 0)
337
+ setStudyCursorIdx(idx);
338
+ saveLastRead(newBook, newChapter);
339
+ return;
340
+ }
291
341
  if (pushHistory) {
292
342
  historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
293
343
  forwardRef.current = [];
@@ -297,7 +347,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
297
347
  setScrollOffset(0);
298
348
  restoreMode();
299
349
  saveLastRead(newBook, newChapter);
300
- }, [restoreMode]);
350
+ }, [restoreMode, verseMap]);
301
351
  const goBack = useCallback(() => {
302
352
  const hist = historyRef.current;
303
353
  if (hist.length === 0)
@@ -342,6 +392,11 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
342
392
  const scrollUp = useCallback(() => scrollTo(scrollOffset - 1), [scrollOffset, scrollTo]);
343
393
  const pageDown = useCallback(() => scrollTo(scrollOffset + viewportH - 2), [scrollOffset, viewportH, scrollTo]);
344
394
  const pageUp = useCallback(() => scrollTo(scrollOffset - viewportH + 2), [scrollOffset, viewportH, scrollTo]);
395
+ // Quarter-of-chapter slider — 4 presses spans top to bottom regardless of length
396
+ const scrollQuarter = useCallback((dir) => {
397
+ const q = Math.max(1, Math.floor(lines.length * 0.25));
398
+ scrollTo(scrollOffset + dir * q);
399
+ }, [lines.length, scrollOffset, scrollTo]);
345
400
  // ── Copy helpers ─────────────────────────────────────────────────────────
346
401
  const doCopy = useCallback(async () => {
347
402
  const lo = selStart !== null ? Math.min(selStart, cursorIdx) : cursorIdx;
@@ -598,26 +653,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
598
653
  return;
599
654
  }
600
655
  if (input === 'j' || key.downArrow) {
601
- setStudyCursorIdx(prev => {
602
- const next = Math.min(prev + 1, verseMap.length - 1);
603
- const entry = verseMap[next];
604
- if (entry && entry.startLine >= scrollOffset + viewportH) {
605
- scrollTo(entry.startLine - viewportH + 3);
606
- }
607
- return next;
608
- });
656
+ setStudyCursorIdx(prev => Math.min(prev + 1, verseMap.length - 1));
609
657
  setStudyRefInput('');
610
658
  return;
611
659
  }
612
660
  if (input === 'k' || key.upArrow) {
613
- setStudyCursorIdx(prev => {
614
- const next = Math.max(prev - 1, 0);
615
- const entry = verseMap[next];
616
- if (entry && entry.startLine < scrollOffset) {
617
- scrollTo(entry.startLine);
618
- }
619
- return next;
620
- });
661
+ setStudyCursorIdx(prev => Math.max(prev - 1, 0));
662
+ setStudyRefInput('');
663
+ return;
664
+ }
665
+ // Cursor to first / last verse (cursor-visible effect handles scroll)
666
+ if (input === 'g') {
667
+ setStudyCursorIdx(0);
668
+ setStudyRefInput('');
669
+ return;
670
+ }
671
+ if (input === 'G') {
672
+ setStudyCursorIdx(Math.max(0, verseMap.length - 1));
621
673
  setStudyRefInput('');
622
674
  return;
623
675
  }
@@ -632,6 +684,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
632
684
  setZoomState(getZoom());
633
685
  return;
634
686
  }
687
+ // Quarter-of-chapter cursor jump (cursor-visible effect handles scroll)
688
+ if (key.tab) {
689
+ const step = Math.max(1, Math.floor(verseMap.length / 4));
690
+ const dir = key.shift ? -1 : 1;
691
+ setStudyCursorIdx(prev => Math.max(0, Math.min(verseMap.length - 1, prev + dir * step)));
692
+ setStudyRefInput('');
693
+ return;
694
+ }
695
+ // Panel scroll (only meaningful when notes panel overflows)
696
+ if (input === ',') {
697
+ setPanelScrollOffset(prev => Math.max(0, prev - 1));
698
+ return;
699
+ }
700
+ if (input === '.') {
701
+ setPanelScrollOffset(prev => Math.min(maxPanelScroll, prev + 1));
702
+ return;
703
+ }
635
704
  // Number keys: build ref number, then follow on Enter or after short delay
636
705
  if (input && /^[0-9]$/.test(input)) {
637
706
  const newInput = studyRefInput + input;
@@ -786,6 +855,14 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
786
855
  scrollTo(maxScroll);
787
856
  return;
788
857
  }
858
+ if (key.tab && key.shift) {
859
+ scrollQuarter(-1);
860
+ return;
861
+ }
862
+ if (key.tab) {
863
+ scrollQuarter(1);
864
+ return;
865
+ }
789
866
  if (input === 'n' || key.rightArrow) {
790
867
  if (searchSubmitted && searchMatches.length > 0) {
791
868
  nextMatch();
@@ -1011,7 +1088,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1011
1088
  const sNoteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1012
1089
  const sOutlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1013
1090
  footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# ref · v range · c copy ·')} ${sNoteFlag} ${chalk.dim('·')} ${sOutlineFlag} ${chalk.dim('· / find')}`;
1014
- footer2 = ` ${chalk.dim('↑↓ verse · ←→ ch · : goto · [/] back/fwd · d exit')}`;
1091
+ const panelHint = panelOverflow ? chalk.dim(' · ,/. note') : '';
1092
+ footer2 = ` ${chalk.dim('↑↓ verse · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · : goto · [/] back/fwd · d exit')}${panelHint}`;
1015
1093
  }
1016
1094
  else if (mode === 'copy') {
1017
1095
  const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
@@ -1021,13 +1099,21 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1021
1099
  const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1022
1100
  const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1023
1101
  footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
1024
- footer2 = ` ${chalk.dim('↑↓ scroll · ←→ ch · +/- width · : goto · H home · [/] back/fwd · q quit')}`;
1102
+ footer2 = ` ${chalk.dim('↑↓ scroll · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · +/- width · : goto · H home · [/] back/fwd · q quit')}`;
1025
1103
  }
1026
1104
  const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
1027
1105
  // ── Render ───────────────────────────────────────────────────────────────
1028
- const studyPanelContent = studyPanel.lines.length > 0
1029
- ? studyPanel.lines.join('\n')
1030
- : null;
1106
+ let studyPanelContent = null;
1107
+ if (panelLinesAll.length > 0) {
1108
+ const parts = [panelVisibleLines.join('\n')];
1109
+ if (panelOverflow) {
1110
+ const lo = panelScrollClamped + 1;
1111
+ const hi = panelScrollClamped + panelVisibleLines.length;
1112
+ const total = panelLinesAll.length;
1113
+ parts.push(chalk.dim(` ── ${lo}–${hi} of ${total} · ,/. scroll ──`));
1114
+ }
1115
+ studyPanelContent = parts.join('\n');
1116
+ }
1031
1117
  return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { children: header }), _jsx(Text, { children: separator }), _jsx(Box, { flexGrow: 1, overflow: "hidden", children: _jsx(Text, { children: content }) }), _jsx(Text, { children: separator }), studyPanelContent && _jsx(Text, { children: studyPanelContent }), studyPanelContent && _jsx(Text, { children: separator }), _jsx(Text, { children: footer1 }), footer2 && _jsx(Text, { children: footer2 })] }));
1032
1118
  }
1033
1119
  // ── Launcher ─────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rv-bible-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",