Add NLT, CSB and NASB via api.bible proxy; new app logo and icons
Build and Push Docker Image / build-and-push (push) Failing after 21s

Reader and Reading Plans get a translation picker. Licensed translations are
fetched through a server-side api.bible proxy so the key stays off the client.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 08:37:48 -04:00
1 parent 986d2e9331
commit 22c80d2bc3
13 files changed
+209 -20

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
// the browser only ever talks to /api/bible/...
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Minimal .env loader so the key doesn't have to be exported in every launcher
// (setup.sh, systemd, docker). Real environment variables win.
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
const API_BASE = 'https://rest.api.bible/v1';
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)' },
};
const CACHE_TTL = 24 * 60 * 60 * 1000;
const CACHE_MAX = 1000;
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// api.bible returns plain text with inline "[12]" verse markers, poetry line
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/ /g, ' ')
.replace(/#/g, '');
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();
if (text) verses.push({ number: Number(parts[i]), text });
}
return verses;
}
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),
})));
});
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
const { book, chapter } = req.params;
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
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 key = `${req.params.translation.toUpperCase()}/${book.toUpperCase()}/${chapter}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_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);
cache.set(key, { at: Date.now(), payload });
res.json(payload);
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
}