Files
hub/bible.js

40 lines
3.2 KiB
JavaScript

// Bible: the text itself (the Berean Standard Bible, public domain) is a static file the browser loads once (public/vendor/bible/bsb.json); this keeps each person's
// place, highlights, notes and bookmarks on the server so they follow them between devices.
const fs = require('fs'), path = require('path');
const users = require('./users');
const file = () => path.join(users.dir(), 'bible.json');
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const BOOK = /^[A-Z0-9]{3}$/;
/** Merge by id; for each id the newest edit (or deletion) wins, so two devices can add highlights without losing each other's. */
function mergeMarks(have, sent) {
const by = new Map((have || []).map(m => [m.id, m]));
for (const m of Array.isArray(sent) ? sent.slice(0, 3000) : []) {
if (!m || !/^[\w-]{1,40}$/.test(m.id || '') || !BOOK.test(m.b || '')) continue;
const v = Math.max(1, Math.round(+m.v) || 1), clean = { id: m.id, t: +m.t || 0, type: ['hl', 'note', 'mark'].includes(m.type) ? m.type : 'hl', b: m.b, c: Math.max(1, Math.round(+m.c) || 1), v, ve: Math.max(v, Math.round(+m.ve) || v),
color: /^[a-z]{3,12}$/.test(m.color || '') ? m.color : 'yellow', note: clip(m.note, 5000), text: clip(m.text, 1500), del: !!m.del };
const old = by.get(m.id); if (!old || clean.t >= old.t) by.set(m.id, clean);
}
return [...by.values()].filter(m => !m.del || Date.now() - m.t < 30 * 864e5);
}
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
save(s); return json(res, 200, s.pos);
}
if (p === '/marks' && req.method === 'POST') {
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load(); s.marks = mergeMarks(s.marks, b); save(s); return json(res, 200, s.marks.filter(m => !m.del));
}
return json(res, 404, { error: 'Not found' });
}
module.exports = { handle, mergeMarks };