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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user