Files
study-bible-project/src/hooks/useBibleReader.js
T
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00

572 lines
25 KiB
JavaScript

// State, data loading, and helpers for the read-only Bible reader page.
// Audio playback stays in App because the study page's book player shares
// the same <audio> element.
import { useCallback, useEffect, useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export { READER_TRANSLATIONS };
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
function useStoredChoice(key, fallback, allowed) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null && allowed.includes(saved) ? saved : fallback;
} catch { return fallback; }
});
const set = (next) => {
setValue(next);
try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
// open; otherwise the reader's own (remembered) translation is used.
export function useBibleReader(currentPage, translationOverride = null) {
// Where you left off (saved on this device, and synced to the account)
const [savedPosition] = useState(() => {
try {
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
} catch { return null; }
});
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
const [readerCommentary, setReaderCommentaryState] = useState(() => {
try {
const saved = localStorage.getItem('reader-commentary');
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
} catch { return COMMENTARIES[0].id; }
});
useEffect(() => {
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
}, [readerBookAbbrev, readerChapter]);
const pageRef = useRef(currentPage);
pageRef.current = currentPage;
const [baseTranslation, setReaderTranslationState] = useState(() => {
try {
const saved = localStorage.getItem('reader-translation');
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
} catch { return 'BSB'; }
});
const readerTranslation = translationOverride ?? baseTranslation;
const [readerCopyright, setReaderCopyright] = useState('');
const [readerTheme, setReaderThemeState] = useState(() => {
try {
const saved = localStorage.getItem('reader-theme');
return ['paper', 'sepia', 'dark'].includes(saved) ? saved : 'paper';
} catch { return 'paper'; }
});
// 'flow' = verses run together like a printed page; 'verses' = one verse per line
const [readerLayout, setReaderLayoutState] = useState(() => {
try { return localStorage.getItem('reader-layout') === 'verses' ? 'verses' : 'flow'; } catch { return 'flow'; }
});
const [readerVerses, setReaderVerses] = useState([]);
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
const [readerLoading, setReaderLoading] = useState(false);
const [readerError, setReaderError] = useState('');
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
const _readerInterlinearCacheRef = useRef({});
// Text size and reading width are per-device (a phone and a tablet want different ones)
const [readerFontSize, setReaderFontSizeState] = useState(() => {
try {
const n = Number(localStorage.getItem('reader-font-size'));
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
} catch { return 1; }
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
const setReaderFontSize = (n) => {
setReaderFontSizeState(n);
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
};
const [readerWide, setReaderWideState] = useState(() => {
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
});
const setReaderWide = (on) => {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// Typography and a second Bible shown beside the first — all per-device, like text size.
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
try {
const saved = localStorage.getItem('start-page');
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
} catch { return 'studies'; }
});
const setStartPagePref = (id) => {
setStartPagePrefState(id);
try { localStorage.setItem('start-page', id); } catch {}
};
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
try {
const saved = localStorage.getItem('plan-translation');
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
} catch { return 'same'; }
});
const setPlanTranslationPref = (id) => {
setPlanTranslationPrefState(id);
try { localStorage.setItem('plan-translation', id); } catch {}
};
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
const _readerCrossRefCacheRef = useRef({});
const [readerSearch, setReaderSearch] = useState('');
const [readerSearchActive, setReaderSearchActive] = useState(false);
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
const [readerInkByPage, setReaderInkByPage] = useState(() => {
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
catch { return {}; }
});
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
return Array.isArray(val) ? val : [];
} catch { return []; }
});
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
} catch { return {}; }
});
const setReaderColorLabel = (color, label) => {
setReaderColorLabelsState((prev) => {
const next = { ...prev };
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
};
// 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 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(translation);
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
_bibleIndexCacheRef.current[translation] = cached.flat;
set('ready');
return;
}
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 = [];
for (const book of data.books ?? []) {
for (const ch of book.chapters ?? []) {
const verses = parseBibleChapter({ chapter: ch.chapter });
for (const v of verses) {
if (v.text) {
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
}
}
}
}
_bibleIndexCacheRef.current[translation] = flat;
set('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB(translation, flat);
} catch {
set('error');
}
};
const setReaderTheme = (id) => {
setReaderThemeState(id);
try { localStorage.setItem('reader-theme', id); } catch {}
};
const setReaderLayout = (id) => {
setReaderLayoutState(id);
try { localStorage.setItem('reader-layout', id); } catch {}
};
const setReaderCommentary = (id) => {
setReaderCommentaryState(id);
try { localStorage.setItem('reader-commentary', id); } catch {}
};
const setReaderTranslation = (id) => {
setReaderTranslationState(id);
try { localStorage.setItem('reader-translation', id); } catch {}
};
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
// through the server's api.bible proxy so the API key never reaches the browser.
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
// slow earlier response replace the page you are now on.
const chapterRequestRef = useRef(0);
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
const requestId = ++chapterRequestRef.current;
setReaderLoading(true);
setReaderError('');
try {
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
if (requestId !== chapterRequestRef.current) return;
setReaderVerses(verses);
setReaderCopyright(copyright);
setReaderTotalChapters(totalChapters);
} catch (err) {
if (requestId !== chapterRequestRef.current) return;
setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]);
} finally {
if (requestId === chapterRequestRef.current) setReaderLoading(false);
}
};
useEffect(() => {
if (currentPage !== 'reader') return;
loadReaderChapter(readerBookAbbrev, readerChapter, readerTranslation);
}, [currentPage, readerBookAbbrev, readerChapter, readerTranslation]);
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
// result), scroll to it and briefly flash its background so it's easy to spot.
useEffect(() => {
if (readerJumpVerse == null || readerLoading) return;
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
if (!el) return;
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
const prevTransition = el.style.transition;
const prevBackground = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s';
el.style.backgroundColor = '#fef08a';
const t = window.setTimeout(() => {
el.style.backgroundColor = prevBackground;
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
}, 1200);
setReaderJumpVerse(null);
return () => window.clearTimeout(t);
}, [readerVerses, readerLoading, readerJumpVerse]);
// Navigates the reader to a specific verse (bookmarks panel, search results) and
// scrolls/flashes it once that chapter's verses have loaded.
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
setReaderBookAbbrev(bookAbbrev);
setReaderChapter(chapter);
setReaderSelectedVerse(null);
setReaderInterlinear(null);
setReaderCrossRefs(null);
setReaderSearch('');
setReaderSearchActive(false);
setReaderBookmarksPanelOpen(false);
setReaderJumpVerse(verseNumber);
};
const readerGoToPreviousChapter = () => {
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
};
const readerGoToNextChapter = () => {
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
};
const handleReaderBookChange = (abbrev) => {
setReaderBookAbbrev(abbrev);
setReaderChapter(1);
setReaderSelectedVerse(null);
setReaderInterlinear(null);
setReaderCrossRefs(null);
setReaderSearch('');
setReaderSearchActive(false);
};
const loadReaderInterlinear = async (bookAbbrev) => {
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
return;
}
setReaderInterlinearLoading(true);
try {
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
if (!res.ok) throw new Error('no interlinear');
const data = await res.json();
_readerInterlinearCacheRef.current[bookAbbrev] = data;
setReaderInterlinear(data);
} catch {
setReaderInterlinear(null);
} finally {
setReaderInterlinearLoading(false);
}
};
useEffect(() => {
if (currentPage !== 'reader') return;
loadReaderInterlinear(readerBookAbbrev);
setReaderSelectedVerse(null);
}, [currentPage, readerBookAbbrev]);
useEffect(() => {
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
// Bookmarks, ink, highlights, cross-refs
const toggleReaderBookmark = (verseKey) => {
setReaderBookmarks((prev) => {
const next = { ...prev };
if (next[verseKey]) {
delete next[verseKey];
} else {
const usedColors = Object.values(next);
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
next[verseKey] = color;
}
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed bookmark back with its colour (used by "Undo").
const restoreReaderBookmark = (verseKey, color) => {
setReaderBookmarks((prev) => {
if (prev[verseKey]) return prev;
const next = { ...prev, [verseKey]: color };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
const next = { ...prev, [key]: strokes };
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
return next;
});
// Debounce server sync — wait 1 s after last stroke before sending
clearTimeout(inkSaveTimerRef.current[key]);
inkSaveTimerRef.current[key] = setTimeout(() => {
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strokes }),
}).catch(() => {}); // silent — localStorage already saved locally
}, 1000);
}, []);
const cycleBookmarkColor = (verseKey) => {
setReaderBookmarks((prev) => {
const cur = prev[verseKey];
if (!cur) return prev;
const idx = BOOKMARK_COLORS.indexOf(cur);
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const addTextHighlight = (highlight) => {
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
setReaderTextHighlights(prev => {
const next = [...prev, { id, ...highlight }];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
if (prev.some((h) => h.id === highlight.id)) return prev;
const next = [...prev, highlight];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id);
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
if (_readerCrossRefCacheRef.current[cacheKey]) {
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
return;
}
setReaderCrossRefsLoading(true);
try {
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('no cross-refs');
const data = await res.json();
const byVerse = {};
for (const item of data.chapter?.content ?? []) {
if (item.references?.length) byVerse[item.verse] = item.references;
}
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
setReaderCrossRefs(byVerse);
} catch {
setReaderCrossRefs(null);
} finally {
setReaderCrossRefsLoading(false);
}
};
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
const next = { ...colorLabels, ...prev };
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
}
// Ink goes through the normal save so it also reaches the account
for (const [key, strokes] of Object.entries(ink ?? {})) {
const [book, chapter] = key.split('_');
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
}
};
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
setReaderInkByPage((local) => {
const merged = { ...local, ...serverInk };
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
return merged;
});
}, []);
// Bookmarks and highlights follow the signed-in user across devices.
const marksSync = useReaderMarksSync({
bookmarks: readerBookmarks, highlights: readerTextHighlights,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
});
// Theme, default translation, layout, commentary and reading position follow the account too.
const prefsSync = usePrefsSync({
values: {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
});
return {
readerBookAbbrev, setReaderBookAbbrev,
readerCommentary, setReaderCommentary,
readerChapter, setReaderChapter,
readerTranslation, setReaderTranslation,
readerCopyright,
readerTheme, setReaderTheme,
readerLayout, setReaderLayout,
readerVerses,
readerTotalChapters,
readerLoading,
readerError,
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
importReaderMarks,
readerTextHighlights,
...marksSync,
...prefsSync,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
loadReaderChapter,
handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
};
}