From c11b6a60bfcac80700ef47408b6cac393ef77c83 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:25:00 -0400 Subject: [PATCH] Reader: whole-Bible search in every translation 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 --- server/bibleApi.js | 61 +++++++++++++++- server/bibleApi.test.js | 23 ++++++ src/components/reader/SearchSheet.jsx | 84 +++++++++++++++------- src/components/reader/SearchSheet.test.jsx | 30 ++++++++ src/hooks/useBibleReader.js | 47 ++++++------ src/hooks/useBibleSearch.js | 49 +++++++++++++ src/pages/ReaderPage.jsx | 21 ++++-- src/pages/ReaderPage.test.jsx | 2 +- 8 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 server/bibleApi.test.js create mode 100644 src/components/reader/SearchSheet.test.jsx create mode 100644 src/hooks/useBibleSearch.js diff --git a/server/bibleApi.js b/server/bibleApi.js index 91cc1c9..d972fae 100644 --- a/server/bibleApi.js +++ b/server/bibleApi.js @@ -22,6 +22,18 @@ const API_BASE = 'https://rest.api.bible/v1'; // Canonical book order (index + 1 = the book number the ESV API uses in ranges). const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV']; +// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations. +const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation']; +const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]])); +ABBREV_BY_NAME.set('psalms', 'PSA'); +ABBREV_BY_NAME.set('song of songs', 'SNG'); + +export function parseEsvReference(ref) { + const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/); + const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase()); + return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null; +} + export const LICENSED_TRANSLATIONS = { ESV: { provider: 'esv', name: 'English Standard Version' }, NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' }, @@ -46,7 +58,7 @@ export function parseChapterText(content) { const parts = clean.split(/\[(\d+)\]/); const verses = []; for (let i = 1; i < parts.length; i += 2) { - const text = parts[i + 1].replace(/\s+/g, ' ').replace(/\s+,$/, '').trim(); + const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, ''); if (text) verses.push({ number: Number(parts[i]), text }); } return verses; @@ -59,6 +71,53 @@ export function registerBibleRoutes(app, requireAuth) { }))); }); + // Whole-Bible search for the licensed translations, proxied so the keys stay + // server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached. + const SEARCH_PAGE_SIZE = 25; + app.get('/api/bible/:translation/search', requireAuth, async (req, res) => { + const id = req.params.translation.toUpperCase(); + const translation = LICENSED_TRANSLATIONS[id]; + const q = String(req.query.q ?? '').trim(); + const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1)); + if (!translation) return res.status(404).json({ error: 'Unknown translation.' }); + if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' }); + const isEsv = translation.provider === 'esv'; + const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY; + if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` }); + + try { + let results; + let total; + if (isEsv) { + const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) }); + const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } }); + if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' }); + if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` }); + const body = await upstream.json(); + total = body.total_results ?? 0; + results = (body.results ?? []).map((r) => { + const ref = parseEsvReference(r.reference); + return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() }; + }).filter(Boolean); + } else { + const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' }); + const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } }); + if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` }); + const body = await upstream.json(); + total = body.data?.total ?? 0; + results = (body.data?.verses ?? []).map((v) => ({ + bookAbbrev: v.bookId, + chapter: Number(String(v.chapterId).split('.')[1]), + verse: Number(String(v.id).split('.')[2]), + text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), + })).filter((r) => r.bookAbbrev && r.chapter && r.verse); + } + res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results }); + } catch { + res.status(502).json({ error: 'Could not reach the Bible provider.' }); + } + }); + // ESV chapter audio (read by David Cochran Heath). The ESV API answers with a // redirect to a public mp3; hand that URL to the browser's