64 lines
6.1 KiB
JavaScript
64 lines
6.1 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 : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } };
|
|
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; } };
|
|
|
|
|
|
// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered.
|
|
const crypto = require('crypto');
|
|
const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json');
|
|
const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
|
const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
|
const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id });
|
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
async function devotion(req, res, p) {
|
|
const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' });
|
|
if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me)));
|
|
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
|
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' });
|
|
if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); }
|
|
if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); }
|
|
const l = loadPrayers();
|
|
if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); }
|
|
const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' });
|
|
if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); }
|
|
if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); }
|
|
return json(res, 404, { error: 'Not found' });
|
|
}
|
|
|
|
async function handle(req, res) {
|
|
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
|
if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p);
|
|
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); }
|
|
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 };
|