Reader: whole-Bible search in every translation
Build and Push Docker Image / build-and-push (push) Failing after 20s
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:
1 parent
3279dddae5
commit
c11b6a60bf
8 files changed
+262
-55
No files matched your search
+26
-21
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user