// 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 };