diff --git a/.gitignore b/.gitignore index d35df42..ebd9c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ .api.pid .api.log .DS_Store +.env diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 140ed42..bb3e867 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png index a0193d3..dd303fb 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png index ce7dfeb..5379a57 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..ca15f3d Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..1b5e317 Binary files /dev/null and b/public/logo.png differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..292be47 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json index b2848f0..e2f9c0d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -19,7 +19,13 @@ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/server/bibleApi.js b/server/bibleApi.js new file mode 100644 index 0000000..635af03 --- /dev/null +++ b/server/bibleApi.js @@ -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.' }); + } + }); +} diff --git a/server/index.js b/server/index.js index 1b39a69..4d37d06 100644 --- a/server/index.js +++ b/server/index.js @@ -23,6 +23,7 @@ import { generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, } from './auth.js'; import { startReminderScheduler } from './reminders.js'; +import { registerBibleRoutes } from './bibleApi.js'; // Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). function userPayload(user) { @@ -450,6 +451,8 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => { // --------------------------------------------------------------------------- // GET /api/reading-plans — the current user's whole progress list +registerBibleRoutes(app, requireAuth); + app.get('/api/reading-plans', requireAuth, (req, res) => { try { res.json(getReadingProgress(req.session.userId)); diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js index 4d40964..7899d43 100644 --- a/src/hooks/useBibleReader.js +++ b/src/hooks/useBibleReader.js @@ -2,14 +2,31 @@ // Audio playback stays in App because the study page's book player shares // the same