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();
});