Reader: whole-Bible search in every translation
Build and Push Docker Image / build-and-push (push) Failing after 20s

BSB and NET are indexed in the browser; ESV, NLT, CSB and NASB are searched
through a server proxy (keys stay server-side). Results page in 25 at a time
and jump to the verse. Also fixes a stray trailing comma in CSB chapter text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 09:25:00 -04:00
1 parent 3279dddae5
commit c11b6a60bf
8 files changed
+262 -55

No files matched your search

+59 -25
View File
@@ -1,12 +1,27 @@
// Search this chapter, or the whole Bible (BSB — the only translation that can be
// downloaded once and searched in the browser).
// Search this chapter, or the whole Bible in the translation being read.
// BSB and NET are downloaded once and searched in the browser; ESV, NLT, CSB and
// NASB are searched through the server (their licences don't allow a bulk copy).
import { useMemo } from 'react';
import Sheet from './Sheet.jsx';
import { SearchIcon } from './icons.jsx';
import { bookOptions } from '../../utils/bibleUtils.jsx';
const nameFor = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wraps each searched word (quotes ignored) in <mark>.
function highlight(text, query) {
const words = [...new Set(query.toLowerCase().replace(/["“”]/g, ' ').split(/\s+/).filter((w) => w.length > 1))];
if (!words.length) return text;
const re = new RegExp(`(${words.map(escapeRegExp).join('|')})`, 'gi');
return text.split(re).map((part, i) => (i % 2 === 1
? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{part}</mark>
: part));
}
export default function SearchSheet({
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter,
indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, onJump, currentBook,
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, currentBook, translation,
isLocal, indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, remote, onJump,
}) {
const q = query.trim().toLowerCase();
const chapterMatches = useMemo(
@@ -14,64 +29,83 @@ export default function SearchSheet({
[scope, q, verses],
);
const snippet = (text, needle) => {
const i = text.toLowerCase().indexOf(needle);
if (i === -1 || !needle) return text;
return (<>{text.slice(0, i)}<mark className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(i, i + needle.length)}</mark>{text.slice(i + needle.length)}</>);
};
const scopeBtn = (id, label, onClick) => (
<button type="button" onClick={onClick}
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
);
const note = (text) => <p className="text-sm text-[var(--r-sub)]">{text}</p>;
const resultRow = (r) => (
<button key={`${r.bookAbbrev}-${r.chapter}-${r.verse}`} type="button" onClick={() => onJump(r.bookAbbrev, r.chapter, r.verse)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{nameFor(r.bookAbbrev)} {r.chapter}:{r.verse}</span>{highlight(r.text, query)}
</button>
);
return (
<Sheet open={open} onClose={onClose} title="Search">
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input autoFocus value={query} onChange={(e) => onQuery(e.target.value)}
placeholder={scope === 'bible' ? 'Search the whole Bible (BSB)…' : `Search ${bookName} ${chapter}…`}
placeholder={scope === 'bible' ? `Search the whole Bible (${translation})…` : `Search ${bookName} ${chapter}…`}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" />
</div>
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
{scopeBtn('bible', 'Whole Bible (BSB)', () => { onScope('bible'); if (indexStatus === 'idle') onLoadIndex(); })}
{scopeBtn('bible', `Whole Bible (${translation})`, () => { onScope('bible'); if (isLocal && indexStatus === 'idle') onLoadIndex(); })}
</div>
{scope === 'chapter' && (
!q ? <p className="text-sm text-[var(--r-sub)]">Type a word to find it in this chapter.</p>
: chapterMatches.length === 0 ? <p className="text-sm text-[var(--r-sub)]">No verses match “{query}”.</p>
!q ? note('Type a word to find it in this chapter.')
: chapterMatches.length === 0 ? note(`No verses match “${query}”.`)
: chapterMatches.map((v) => (
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.number}</span>{snippet(v.text, q)}
<span className="mr-1.5 font-medium">{v.number}</span>{highlight(v.text, query)}
</button>
))
)}
{scope === 'bible' && (
{scope === 'bible' && isLocal && (
<>
{indexStatus === 'loading' && <p className="text-sm text-[var(--r-sub)]">Loading the full Bible for search. This happens once per visit (about 7 MB).</p>}
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
{indexStatus === 'error' && <p className="text-sm text-red-600">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
{indexStatus === 'ready' && (!deferredBibleQuery
? <p className="text-sm text-[var(--r-sub)]">Type a word to search all 66 books.</p>
? note('Type a word to search all 66 books.')
: bibleMatches.length === 0
? <p className="text-sm text-[var(--r-sub)]">No verses match “{query}”.</p>
? note(`No verses match “${query}”.`)
: (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
</p>
{bibleMatches.slice(0, 100).map((v) => (
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button" onClick={() => onJump(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.bookName} {v.chapter}:{v.verse}</span>{snippet(v.text, deferredBibleQuery)}
</button>
))}
{bibleMatches.slice(0, 100).map(resultRow)}
</>
))}
</>
)}
{scope === 'bible' && !isLocal && (
<>
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
{!remote.tooShort && remote.error && <p className="text-sm text-red-600">{remote.error}</p>}
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
{remote.results.length > 0 && (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{remote.total} match{remote.total === 1 ? '' : 'es'} in {translation}
</p>
{remote.results.map(resultRow)}
{remote.hasMore && (
<button type="button" onClick={remote.loadMore} disabled={remote.loading}
className="mt-2 w-full rounded-lg border border-[var(--r-line)] py-2 text-sm text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-50">
{remote.loading ? 'Loading…' : 'Show more'}
</button>
)}
</>
)}
</>
)}
</Sheet>
);
}
@@ -0,0 +1,30 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, vi } from 'vitest';
import SearchSheet from './SearchSheet.jsx';
const base = {
open: true, onClose: () => {}, query: 'shepherd', onQuery: () => {}, scope: 'bible', onScope: () => {},
verses: [], bookName: 'John', chapter: 3, currentBook: 'JHN', translation: 'NLT', isLocal: false,
indexStatus: 'idle', onLoadIndex: () => {}, bibleMatches: [], deferredBibleQuery: '',
};
test('server-searched translations list results with highlights and jump on tap', () => {
const onJump = vi.fn();
const remote = {
tooShort: false, loading: false, error: '', page: 1, total: 2, hasMore: true, loadMore: vi.fn(),
results: [{ bookAbbrev: 'GEN', chapter: 4, verse: 2, text: 'Abel became a shepherd.' }, { bookAbbrev: 'PSA', chapter: 23, verse: 1, text: 'The Lord is my shepherd.' }],
};
render(<SearchSheet {...base} remote={remote} onJump={onJump} />);
expect(screen.getByText('2 matches in NLT')).toBeTruthy();
expect(screen.getAllByText('shepherd')[0].tagName).toBe('MARK');
fireEvent.click(screen.getByText(/Genesis 4:2/));
expect(onJump).toHaveBeenCalledWith('GEN', 4, 2);
fireEvent.click(screen.getByText('Show more'));
expect(remote.loadMore).toHaveBeenCalled();
});
test('shows the server error, e.g. a rate limit', () => {
const remote = { tooShort: false, loading: false, error: 'ESV rate limit reached. Try again in a minute.', page: 0, total: 0, hasMore: false, loadMore: () => {}, results: [] };
render(<SearchSheet {...base} translation="ESV" remote={remote} onJump={() => {}} />);
expect(screen.getByText(/rate limit/)).toBeTruthy();
});
+26 -21
View File
@@ -62,7 +62,6 @@ export function useBibleReader(currentPage) {
const [readerSearch, setReaderSearch] = useState('');
const [readerSearchActive, setReaderSearchActive] = useState(false);
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
const [readerInkByPage, setReaderInkByPage] = useState(() => {
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
@@ -77,27 +76,33 @@ export function useBibleReader(currentPage) {
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
// entire translation once (~7MB) and searches an in-memory flat verse index
// client-side. Cached per translation for the rest of the session (not persisted
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
// that's cheap enough to rebuild once per visit).
const loadBibleIndex = async () => {
if (_bibleIndexCacheRef.current.BSB) {
setBibleIndexStatus('ready');
return;
}
setBibleIndexStatus('loading');
// The HelloAO API has no search endpoint, so whole-Bible search for the free
// translations (BSB, NET) fetches the entire translation once (~7MB) and searches
// an in-memory flat verse index client-side. Cached per translation for the rest of
// the visit, and in IndexedDB for a while. Licensed translations use the server
// search proxy instead (see useBibleSearch).
const [bibleIndexState, setBibleIndexState] = useState({ translation: null, status: 'idle' });
const bibleIndexStatusFor = (translation) => {
if (_bibleIndexCacheRef.current[translation]) return 'ready';
return bibleIndexState.translation === translation ? bibleIndexState.status : 'idle';
};
const loadBibleIndex = async (translation = 'BSB') => {
const set = (status) => setBibleIndexState({ translation, status });
if (_bibleIndexCacheRef.current[translation]) { set('ready'); return; }
const source = READER_TRANSLATIONS.find((t) => t.id === translation);
if (!source || source.licensed) { set('error'); return; }
set('loading');
try {
// Check IndexedDB cache before hitting the network (~7 MB fetch)
const cached = await getBibleIndexFromIDB('BSB');
const cached = await getBibleIndexFromIDB(translation);
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
_bibleIndexCacheRef.current.BSB = cached.flat;
setBibleIndexStatus('ready');
_bibleIndexCacheRef.current[translation] = cached.flat;
set('ready');
return;
}
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/complete.json`);
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
const data = await res.json();
const flat = [];
@@ -111,12 +116,12 @@ export function useBibleReader(currentPage) {
}
}
}
_bibleIndexCacheRef.current.BSB = flat;
setBibleIndexStatus('ready');
_bibleIndexCacheRef.current[translation] = flat;
set('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB('BSB', flat);
saveBibleIndexToIDB(translation, flat);
} catch {
setBibleIndexStatus('error');
set('error');
}
};
@@ -381,7 +386,7 @@ export function useBibleReader(currentPage) {
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
bibleIndexStatus,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
loadReaderChapter,
+49
View File
@@ -0,0 +1,49 @@
// Whole-Bible search for translations that can't be downloaded and indexed in the
// browser (ESV, NLT, CSB, NASB): queries go through the server's search proxy.
// Debounced, cancels stale requests, and pages in 25 verses at a time.
import { useCallback, useEffect, useRef, useState } from 'react';
const DEBOUNCE_MS = 450;
const EMPTY = { results: [], total: 0, page: 0, hasMore: false, loading: false, error: '' };
export function useBibleSearch(translation, query, enabled) {
const [state, setState] = useState(EMPTY);
const controllerRef = useRef(null);
const q = query.trim();
const run = useCallback(async (page) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setState((s) => ({ ...s, loading: true, error: '' }));
try {
const res = await fetch(`/api/bible/${translation}/search?q=${encodeURIComponent(q)}&page=${page}`, {
credentials: 'include', signal: controller.signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Search failed.');
setState((s) => ({
results: page === 1 ? data.results : [...s.results, ...data.results],
total: data.total, page: data.page, hasMore: data.hasMore, loading: false, error: '',
}));
} catch (err) {
if (err.name === 'AbortError') return;
setState((s) => ({ ...s, loading: false, error: err.message || 'Search failed.' }));
}
}, [translation, q]);
useEffect(() => {
if (!enabled || q.length < 2) {
controllerRef.current?.abort();
setState(EMPTY);
return undefined;
}
const timer = window.setTimeout(() => run(1), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [enabled, q, run]);
useEffect(() => () => controllerRef.current?.abort(), []);
const loadMore = () => { if (!state.loading && state.hasMore) run(state.page + 1); };
return { ...state, loadMore, tooShort: q.length < 2 };
}
+14 -7
View File
@@ -6,6 +6,7 @@ import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
import ReaderText from '../components/reader/ReaderText.jsx';
@@ -45,7 +46,7 @@ export default function ReaderPage() {
readerSearchScope, setReaderSearchScope,
readerAudioState,
audioProgress,
bibleIndexStatus,
bibleIndexStatusFor,
audioNarrator, setAudioNarrator,
_bibleIndexCacheRef,
readerGoToPreviousChapter,
@@ -142,11 +143,15 @@ export default function ReaderPage() {
return m.done ? null : { label: 'Finish plan', done: false, onClick: markDone };
}, [planMatches]); // eslint-disable-line react-hooks/exhaustive-deps
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
// and let typing stay responsive while results catch up via useDeferredValue.
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
// Whole-Bible search. BSB and NET are indexed in the browser (lowercased once, not per
// keystroke, with typing kept responsive via useDeferredValue); ESV, NLT, CSB and NASB
// go through the server's search proxy.
const localSearch = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.licensed !== true;
const indexStatus = bibleIndexStatusFor(readerTranslation);
const bibleIndex = localSearch && indexStatus === 'ready' ? (_bibleIndexCacheRef.current[readerTranslation] ?? null) : null;
const bibleIndexLower = useMemo(() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null), [bibleIndex]);
const deferredBibleQuery = useDeferredValue(sheet === 'search' && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '');
const wholeBibleSearching = sheet === 'search' && readerSearchScope === 'bible';
const deferredBibleQuery = useDeferredValue(wholeBibleSearching && localSearch ? readerSearch.trim().toLowerCase() : '');
const bibleMatches = useMemo(() => {
if (!bibleIndex || !deferredBibleQuery) return [];
const out = [];
@@ -155,6 +160,7 @@ export default function ReaderPage() {
}
return out;
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch);
const openSheet = (name) => {
setSheet(name);
@@ -407,8 +413,9 @@ export default function ReaderPage() {
query={readerSearch} onQuery={setReaderSearch}
scope={readerSearchScope} onScope={setReaderSearchScope}
verses={readerVerses} bookName={readerBook?.name} chapter={readerChapter} currentBook={readerBookAbbrev}
indexStatus={bibleIndexStatus} onLoadIndex={loadBibleIndex}
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery}
translation={readerTranslation} isLocal={localSearch}
indexStatus={indexStatus} onLoadIndex={() => loadBibleIndex(readerTranslation)}
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump}
/>
<BookmarksSheet
+1 -1
View File
@@ -18,7 +18,7 @@ const ctx = {
readerSearch: '', setReaderSearch: noop, setReaderSearchActive: noop,
readerSearchScope: 'chapter', setReaderSearchScope: noop,
readerAudioState: { status: 'idle' }, audioProgress: { currentTime: 0, duration: 0 },
bibleIndexStatus: 'idle', audioNarrator: 'david', setAudioNarrator: noop,
bibleIndexStatusFor: () => 'idle', audioNarrator: 'david', setAudioNarrator: noop,
_bibleIndexCacheRef: { current: {} },
readerGoToPreviousChapter: noop, readerGoToNextChapter: noop, jumpToReaderVerse: noop,
toggleReaderBookmark: noop, cycleBookmarkColor: noop, loadReaderCrossRefs: noop, loadBibleIndex: noop,