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

+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 };
}