New reading surface with paper/sepia/dark themes, slim top and bottom bars, and sheets for navigation, search, settings, audio and per-verse study tools. Plan progress is built into the reader. Page-flip mode removed. ESV added via the server-side proxy (ESV_API_KEY). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e8420a1ae
commit
9e722a296d
17 files changed
+903
-791
No files matched your search
+59
-28
@@ -19,14 +19,22 @@ if (existsSync(envPath)) {
|
||||
|
||||
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'];
|
||||
|
||||
export const LICENSED_TRANSLATIONS = {
|
||||
NLT: { bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||
CSB: { bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||
NASB: { bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||
ESV: { provider: 'esv', name: 'English Standard Version' },
|
||||
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||
};
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const CACHE_MAX = 1000;
|
||||
// Crossway limits how much ESV text an app may store, so ESV chapters are only
|
||||
// held briefly and in small numbers (enough to absorb repeat loads).
|
||||
const ESV_CACHE_TTL = 60 * 60 * 1000;
|
||||
const ESV_CACHE_MAX = 20;
|
||||
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||
|
||||
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
||||
@@ -47,7 +55,7 @@ export function parseChapterText(content) {
|
||||
export function registerBibleRoutes(app, requireAuth) {
|
||||
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
||||
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
||||
id, name: t.name, available: Boolean(process.env.API_BIBLE_KEY),
|
||||
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
|
||||
})));
|
||||
});
|
||||
|
||||
@@ -58,34 +66,57 @@ export function registerBibleRoutes(app, requireAuth) {
|
||||
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
||||
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
}
|
||||
const apiKey = process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: 'API_BIBLE_KEY is not configured on the server.' });
|
||||
const id = req.params.translation.toUpperCase();
|
||||
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.` });
|
||||
|
||||
const key = `${req.params.translation.toUpperCase()}/${book.toUpperCase()}/${chapter}`;
|
||||
const key = `${id}/${book.toUpperCase()}/${chapter}`;
|
||||
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL) return res.json(hit.payload);
|
||||
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||
{ headers: { 'api-key': apiKey } },
|
||||
);
|
||||
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.data?.content);
|
||||
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||
const payload = {
|
||||
translation: req.params.translation.toUpperCase(),
|
||||
verses,
|
||||
copyright: body.data?.copyright ?? '',
|
||||
fumsToken: body.meta?.fumsToken ?? null,
|
||||
};
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
let payload;
|
||||
if (isEsv) {
|
||||
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
|
||||
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||
const qs = new URLSearchParams({
|
||||
q: `${prefix}001-${prefix}999`,
|
||||
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
|
||||
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
|
||||
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
|
||||
});
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${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();
|
||||
const verses = parseChapterText(body.passages?.[0]);
|
||||
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
payload = { translation: id, verses, copyright: '', fumsToken: null };
|
||||
} else {
|
||||
const qs = new URLSearchParams({
|
||||
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||
{ headers: { 'api-key': apiKey } },
|
||||
);
|
||||
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.data?.content);
|
||||
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
|
||||
}
|
||||
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
|
||||
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
if (isEsv) {
|
||||
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
|
||||
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
|
||||
}
|
||||
cache.set(key, { at: Date.now(), payload });
|
||||
res.json(payload);
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user