Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s

- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:45:32 -04:00
1 parent 54cb1c217d
commit 207bfcb919
45 files changed
+1669 -75

No files matched your search

+2
View File
@@ -17,11 +17,13 @@ export function buildAccountExport(user, store, now = new Date()) {
projects: store.projects(),
reader: {
bookmarks: marks.bookmarks ?? {},
bookmarkNotes: marks.bookmarkNotes ?? {},
highlights: marks.highlights ?? [],
colorLabels: prefs.colorLabels?.v ?? {},
ink: store.ink() ?? {},
},
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
prayers: store.prayers?.() ?? [],
readingPlans: {
progress: plans.progress ?? [],
customPlans: plans.customPlans ?? [],
+116 -6
View File
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -90,6 +91,15 @@ export function initDb() {
`);
}
// Optional daily prayer reminder, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'prayer_enabled')) {
db.exec(`
ALTER TABLE users ADD COLUMN prayer_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN prayer_time TEXT;
ALTER TABLE users ADD COLUMN last_prayer_date TEXT;
`);
}
// Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
@@ -125,6 +135,29 @@ export function initDb() {
);
`);
// The prayer journal: one row per account (the whole list as JSON; see server/prayers.js).
db.exec(`
CREATE TABLE IF NOT EXISTS prayers (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Earlier copies of each study (compressed), for "Restore an earlier version".
db.exec(`
CREATE TABLE IF NOT EXISTS project_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
state_at INTEGER NOT NULL,
title TEXT,
chunks INTEGER NOT NULL DEFAULT 0,
words INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_versions_project ON project_versions(project_id, state_at);
`);
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
db.exec(`
CREATE TABLE IF NOT EXISTS collaborators (
@@ -239,6 +272,7 @@ function parseUserRow(row) {
totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
prayerEnabled: !!row.prayerEnabled,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
@@ -249,7 +283,8 @@ const USER_SELECT = `
podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime
FROM users
`;
@@ -297,6 +332,36 @@ export function setVotdSettings(userId, { votdEnabled, votdTime }) {
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
}
/** Turns the daily prayer reminder on or off, and says when to send it. */
export function setPrayerReminder(userId, { prayerEnabled, prayerTime }) {
db.prepare('UPDATE users SET prayer_enabled = ?, prayer_time = ? WHERE id = ?').run(prayerEnabled ? 1 : 0, prayerTime || null, userId);
}
export function getUsersWithPrayerReminder() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, prayer_time AS prayerTime,
last_prayer_date AS lastPrayerDate, reminder_tz AS reminderTz
FROM users
WHERE prayer_enabled = 1 AND ntfy_topic IS NOT NULL AND prayer_time IS NOT NULL
`).all();
}
export function setLastPrayerDate(userId, dateString) {
db.prepare('UPDATE users SET last_prayer_date = ? WHERE id = ?').run(dateString, userId);
}
export function getPrayers(userId) {
const row = db.prepare('SELECT data FROM prayers WHERE user_id = ?').get(userId);
try { return row ? JSON.parse(row.data) : []; } catch { return []; }
}
export function setPrayers(userId, list) {
db.prepare(`
INSERT INTO prayers (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(list), Date.now());
}
/** Every account with the verse of the day turned on and a topic to send it to. */
export function getUsersWithVotdEnabled() {
return db.prepare(`
@@ -421,19 +486,19 @@ export function setUserPrefs(userId, prefs) {
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
export function getReaderMarks(userId) {
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 };
if (!row) return { bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 };
const data = JSON.parse(row.data);
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], bookmarkNotes: data.bookmarkNotes ?? {}, updatedAt: row.updatedAt };
}
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
export function setReaderMarks(userId, { bookmarks, highlights }) {
export function setReaderMarks(userId, { bookmarks, highlights, bookmarkNotes = {} }) {
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
db.prepare(`
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt);
`).run(userId, JSON.stringify({ bookmarks, highlights, bookmarkNotes }), updatedAt);
return updatedAt;
}
@@ -553,7 +618,8 @@ export function upsertProject(project, userId) {
}
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(project.id);
if (prev) keepVersion(project.id, prev);
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
@@ -571,6 +637,48 @@ export function upsertProject(project, userId) {
return { id: project.id, title: project.title, lastEdited, chapterSummary };
}
// ---------------------------------------------------------------------------
// Earlier versions
// ---------------------------------------------------------------------------
/** Keeps the copy that is about to be replaced (when it's been long enough since the last kept one), then trims the oldest. */
function keepVersion(projectId, prev, force = false) {
const last = db.prepare('SELECT MAX(state_at) AS at FROM project_versions WHERE project_id = ?').get(projectId)?.at;
if (!shouldSnapshot({ prevEditedAt: prev.lastEdited, lastKeptAt: last ?? undefined, force })) return;
let project;
try { project = JSON.parse(prev.data); } catch { return; }
if (db.prepare('SELECT 1 FROM project_versions WHERE project_id = ? AND state_at = ?').get(projectId, prev.lastEdited)) return;
const { chunks, words } = summarizeProject(project);
db.prepare('INSERT INTO project_versions (project_id, state_at, title, chunks, words, data) VALUES (?, ?, ?, ?, ?, ?)')
.run(projectId, prev.lastEdited, project.title ?? '', chunks, words, pack(prev.data));
db.prepare(`DELETE FROM project_versions WHERE project_id = ? AND id NOT IN (SELECT id FROM project_versions WHERE project_id = ? ORDER BY state_at DESC LIMIT ?)`).run(projectId, projectId, MAX_VERSIONS);
}
export function listVersions(projectId) {
return db.prepare('SELECT id, state_at AS savedAt, title, chunks, words FROM project_versions WHERE project_id = ? ORDER BY state_at DESC').all(projectId);
}
export function getVersion(projectId, versionId) {
const row = db.prepare('SELECT data FROM project_versions WHERE project_id = ? AND id = ?').get(projectId, versionId);
if (!row) return null;
try { return unpack(row.data); } catch { return null; }
}
/** Puts an earlier version back as the current one. The copy it replaces is kept first, so a restore can be undone too. */
export function restoreVersion(projectId, versionId, userId) {
const old = getVersion(projectId, versionId);
if (!old || !canEditProject(projectId, userId)) return null;
return db.transaction(() => {
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(projectId);
if (prev) keepVersion(projectId, prev, true);
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
const restored = { ...old, id: projectId, lastEdited };
db.prepare('UPDATE projects SET title = ?, last_edited = ?, chapter_summary = ?, data = ? WHERE id = ?')
.run(restored.title, lastEdited, buildSummary(restored), JSON.stringify(restored), projectId);
return restored;
})();
}
/**
* Delete a project by id, scoped to userId. No-op if not found/not owned.
*/
@@ -583,6 +691,7 @@ export function deleteProject(id, userId) {
}
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
})();
}
@@ -760,6 +869,7 @@ export function adminGetProject(id) {
/** Deletes any project by id, regardless of owner. */
export function adminDeleteProject(id) {
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
}
+57 -2
View File
@@ -16,8 +16,9 @@ import {
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings, setVotdSettings,
setReminderSettings, setVotdSettings, setPrayerReminder, getPrayers, setPrayers,
getReaderHistory, setReaderHistory,
canEditProject, listVersions, getVersion, restoreVersion,
isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite,
getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite,
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
@@ -29,6 +30,7 @@ import {
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
generateResetCode, normalizeResetCode,
} from './auth.js';
import { mergePrayers } from './prayers.js';
import { votdAvailable } from './votd.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
@@ -51,6 +53,7 @@ function userPayload(user) {
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
reminderTime: user.reminderTime ?? null,
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
};
}
@@ -395,6 +398,13 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
}
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
const prayerEnabled = !!req.body?.prayerEnabled;
const prayerTime = String(req.body?.prayerTime ?? '').trim();
if (prayerEnabled) {
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
}
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
setReminderSettings(req.session.userId, {
ntfyUrl: ntfyUrl || null,
ntfyTopic: ntfyTopic || null,
@@ -404,7 +414,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
});
res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
votdEnabled, votdTime: votdTime || null,
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
});
} catch (err) {
console.error('PATCH /api/auth/reminder-settings error:', err);
@@ -519,6 +529,7 @@ app.get('/api/account/export', requireAuth, (req, res) => {
ink: () => getAllReaderInk(userId),
plans: () => getReadingProgress(userId),
history: () => getReaderHistory(userId),
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
});
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
res.json(backup);
@@ -587,6 +598,32 @@ app.put('/api/projects/:id', requireAuth, (req, res) => {
}
});
// ---------------------------------------------------------------------------
// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too)
// ---------------------------------------------------------------------------
app.get('/api/projects/:id/versions', requireAuth, (req, res) => {
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
res.json({ versions: listVersions(req.params.id) });
});
app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => {
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
const version = getVersion(req.params.id, Number(req.params.versionId));
if (!version) return res.status(404).json({ error: 'That version is gone.' });
res.json(version);
});
app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => {
try {
const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId);
if (!restored) return res.status(404).json({ error: 'That version is gone.' });
res.json(restored);
} catch (err) {
console.error('POST restore error:', err);
res.status(500).json({ error: 'Failed to restore that version.' });
}
});
// ---------------------------------------------------------------------------
// DELETE /api/projects/:id — remove a project owned by the current user
// ---------------------------------------------------------------------------
@@ -662,6 +699,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => {
}
});
// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole
app.get('/api/prayers', requireAuth, (req, res) => {
try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) {
console.error('GET /api/prayers error:', err);
res.status(500).json({ error: 'Failed to load your prayers.' });
}
});
app.put('/api/prayers', requireAuth, (req, res) => {
try {
const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers);
setPrayers(req.session.userId, merged);
res.json({ prayers: merged });
} catch (err) {
console.error('PUT /api/prayers error:', err);
res.status(500).json({ error: 'Failed to save your prayers.' });
}
});
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
app.get('/api/reader/history', requireAuth, (req, res) => {
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
+60
View File
@@ -0,0 +1,60 @@
// The prayer journal, kept per account. Each prayer carries its own `updatedAt`, so two devices that each add or
// change prayers merge cleanly: the newer version of each prayer wins, the days prayed are combined, and a
// deleted prayer stays as a small marker for a while so another device can't bring it back.
const ID = /^[\w-]{1,64}$/;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const BOOK = /^[1-3A-Z]{3}$/;
const MAX_PRAYERS = 2000;
const MAX_DAYS = 400;
const KEEP_DELETED_MS = 90 * 86_400_000;
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
const time = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
// One prayer from the browser, rebuilt field by field; null when it isn't one.
export function cleanPrayer(raw, now = Date.now()) {
if (!raw || typeof raw !== 'object' || !ID.test(String(raw.id ?? ''))) return null;
const updatedAt = Math.min(time(raw.updatedAt, 0), now + 86_400_000);
if (raw.deleted === true) return { id: raw.id, deleted: true, updatedAt };
const body = text(raw.text, 2000).trim();
if (!body) return null;
const verses = (Array.isArray(raw.verses) ? raw.verses : []).slice(0, 8)
.filter((v) => v && BOOK.test(String(v.book)) && int(v.chapter, 1, 200) && (v.verse == null || int(v.verse, 1, 200)))
.map((v) => ({ book: v.book, chapter: v.chapter, verse: v.verse ?? null, label: text(v.label, 60) }));
const prayedOn = [...new Set((Array.isArray(raw.prayedOn) ? raw.prayedOn : []).filter((d) => typeof d === 'string' && DAY.test(d)))].sort().slice(-MAX_DAYS);
return {
id: raw.id, text: body, forWhom: text(raw.forWhom, 80).trim(), verses, prayedOn,
createdAt: time(raw.createdAt, updatedAt), updatedAt,
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
answerNote: text(raw.answerNote, 1000).trim(),
};
}
export function cleanPrayers(list, now = Date.now()) {
const seen = new Set();
const out = [];
for (const raw of (Array.isArray(list) ? list : []).slice(0, MAX_PRAYERS)) {
const p = cleanPrayer(raw, now);
if (!p || seen.has(p.id)) continue;
seen.add(p.id);
out.push(p);
}
return out;
}
// Two copies of the journal -> one. The newer version of each prayer wins; the days prayed are the union of both.
export function mergePrayers(a, b, now = Date.now()) {
const byId = new Map();
for (const p of [...cleanPrayers(a, now), ...cleanPrayers(b, now)]) {
const have = byId.get(p.id);
if (!have) { byId.set(p.id, p); continue; }
const [newer, older] = p.updatedAt >= have.updatedAt ? [p, have] : [have, p];
byId.set(p.id, newer.deleted ? newer : { ...newer, prayedOn: [...new Set([...(newer.prayedOn ?? []), ...(older.deleted ? [] : older.prayedOn ?? [])])].sort().slice(-MAX_DAYS) });
}
return [...byId.values()]
.filter((p) => !(p.deleted && now - p.updatedAt > KEEP_DELETED_MS)) // old markers are forgotten
.sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
}
export const openPrayers = (list) => list.filter((p) => !p.deleted && !p.answeredAt);
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { cleanPrayer, cleanPrayers, mergePrayers, openPrayers } from './prayers.js';
const NOW = 1_800_000_000_000;
const p = (id, extra = {}) => ({ id, text: `Pray ${id}`, forWhom: '', verses: [], prayedOn: [], createdAt: NOW - 5000, updatedAt: NOW - 1000, answeredAt: null, answerNote: '', ...extra });
test('only well-formed prayers are kept, rebuilt field by field', () => {
const out = cleanPrayer({ id: 'a-1', text: ' For my family ', forWhom: 'Home', evil: '<script>', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }, { book: 'xx', chapter: 1 }, { book: 'JHN', chapter: 99999 }], prayedOn: ['2026-10-01', 'nope', '2026-10-01'], updatedAt: NOW - 1 }, NOW);
expect(out).toMatchObject({ id: 'a-1', text: 'For my family', forWhom: 'Home', prayedOn: ['2026-10-01'], answeredAt: null });
expect(out.verses).toEqual([{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }]);
expect('evil' in out).toBe(false);
expect(cleanPrayer({ id: 'bad id!', text: 'x' }, NOW)).toBeNull();
expect(cleanPrayer({ id: 'a', text: ' ' }, NOW)).toBeNull();
expect(cleanPrayer(null)).toBeNull();
expect(cleanPrayer({ id: 'g', deleted: true, updatedAt: NOW }, NOW)).toEqual({ id: 'g', deleted: true, updatedAt: NOW });
expect(cleanPrayers([p('a'), p('a'), 5, p('b')], NOW).map((x) => x.id)).toEqual(['a', 'b']);
});
test('the newer version of a prayer wins and the days prayed are combined', () => {
const mine = [p('a', { text: 'old wording', prayedOn: ['2026-10-01'], updatedAt: NOW - 5000 })];
const theirs = [p('a', { text: 'new wording', prayedOn: ['2026-10-02'], updatedAt: NOW - 1000 }), p('b')];
const merged = mergePrayers(mine, theirs, NOW);
expect(merged.find((x) => x.id === 'a')).toMatchObject({ text: 'new wording', prayedOn: ['2026-10-01', '2026-10-02'] });
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
expect(mergePrayers(merged, merged, NOW)).toEqual(merged);
});
test('a deleted prayer is not brought back by an older copy, and old markers are forgotten', () => {
const gone = [{ id: 'a', deleted: true, updatedAt: NOW - 100 }];
const stale = [p('a', { updatedAt: NOW - 9000 })];
expect(mergePrayers(gone, stale, NOW)).toEqual([{ id: 'a', deleted: true, updatedAt: NOW - 100 }]);
expect(mergePrayers([{ id: 'z', deleted: true, updatedAt: NOW - 100 * 86_400_000 }], [], NOW)).toEqual([]);
expect(mergePrayers([p('a', { updatedAt: NOW })], [{ id: 'a', deleted: true, updatedAt: NOW - 5 }], NOW)[0].deleted).toBeUndefined(); // written again after the delete
});
test('open prayers are the ones not answered or deleted', () => {
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
});
+11 -1
View File
@@ -5,6 +5,7 @@ const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16"
const BOOK = /^[1-3A-Za-z]{3}$/;
const MAX_BOOKMARKS = 5000;
const MAX_HIGHLIGHTS = 20000;
const MAX_NOTE = 500;
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
@@ -20,6 +21,14 @@ export function cleanMarks(body) {
}
}
// A short note on a bookmark: { "JHN-3-16": "my note" }
const bookmarkNotes = {};
if (body.bookmarkNotes && typeof body.bookmarkNotes === 'object' && !Array.isArray(body.bookmarkNotes)) {
for (const [key, note] of Object.entries(body.bookmarkNotes).slice(0, MAX_BOOKMARKS)) {
if (BOOKMARK_KEY.test(key) && typeof note === 'string' && note.trim()) bookmarkNotes[key] = note.slice(0, MAX_NOTE);
}
}
const highlights = [];
if (Array.isArray(body.highlights)) {
const seen = new Set();
@@ -39,8 +48,9 @@ export function cleanMarks(body) {
// Which translation the words were selected in, and whether it is a whole-verse highlight
if (typeof h.translation === 'string' && /^[A-Za-z0-9]{2,8}$/.test(h.translation)) entry.translation = h.translation;
if (h.whole === true) entry.whole = true;
if (typeof h.note === 'string' && h.note.trim()) entry.note = h.note.slice(0, MAX_NOTE);
highlights.push(entry);
}
}
return { bookmarks, highlights };
return { bookmarks, highlights, bookmarkNotes };
}
+17 -3
View File
@@ -15,7 +15,7 @@ test('a non-object body is rejected', () => {
test('good bookmarks and highlights pass through unchanged', () => {
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
expect(cleanMarks(body)).toEqual(body);
expect(cleanMarks(body)).toEqual({ ...body, bookmarkNotes: {} });
});
test('malformed bookmarks are dropped, good ones kept', () => {
@@ -38,7 +38,7 @@ test('highlights are validated field by field and de-duplicated by id', () => {
});
test('missing sections become empty ones, and unknown fields are not stored', () => {
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
});
@@ -48,7 +48,7 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
db.initDb();
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 });
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
expect(t2).toBeGreaterThan(t1);
@@ -70,3 +70,17 @@ test('a highlight keeps the translation it was made in and whether it covers the
expect(out[1].translation).toBeUndefined();
expect(out[1].whole).toBeUndefined();
});
test('notes on highlights and bookmarks are kept, trimmed, and dropped when empty or malformed', async () => {
const { cleanMarks } = await import('./readerMarks.js');
const base = { book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a' };
const out = cleanMarks({
bookmarks: { 'JHN-3-16': '#fef08a' },
bookmarkNotes: { 'JHN-3-16': 'Memorise this', 'JHN-3-17': ' ', 'bad key': 'x', 'ROM-8-1': 5 },
highlights: [{ ...base, id: 'a', note: 'The heart of the gospel' }, { ...base, id: 'b', note: ' ' }, { ...base, id: 'c', note: 'x'.repeat(900) }],
});
expect(out.bookmarkNotes).toEqual({ 'JHN-3-16': 'Memorise this' });
expect(out.highlights[0].note).toBe('The heart of the gospel');
expect(out.highlights[1].note).toBeUndefined();
expect(out.highlights[2].note).toHaveLength(500);
});
+33 -1
View File
@@ -4,7 +4,8 @@
// No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
import { openPrayers } from './prayers.js';
import { fetchVerseOfDay, votdMessage } from './votd.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
@@ -142,10 +143,41 @@ export async function checkVotd(now = new Date()) {
}
}
// The message for the daily prayer reminder: how many requests are open, and the first few.
export function prayerMessage(open) {
const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`);
const more = open.length - lines.length;
return {
title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`,
body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`,
tags: 'pray',
};
}
// Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when
// the journal has no open requests.
export async function checkPrayerReminders(now = new Date()) {
for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) {
const open = openPrayers(getPrayers(user.id));
if (open.length === 0) continue;
const key = `prayer:${user.id}`;
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(key, now.getTime());
try {
await sendNtfy(user, prayerMessage(open));
_lastAttempt.delete(key);
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() {
setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
}, 60 * 1000);
}
+12
View File
@@ -99,3 +99,15 @@ test('private and loopback addresses are rejected as reminder servers', async ()
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
});
test('the prayer reminder lists the first few open requests and counts the rest', async () => {
const { prayerMessage } = await import('./reminders.js');
const open = [
{ text: 'Healing for the whole family and a peaceful recovery after the surgery next week, with no complications at all', forWhom: 'Mum' },
{ text: 'Wisdom at work', forWhom: '' }, { text: 'Rain', forWhom: 'Farm' }, { text: 'Peace', forWhom: '' }, { text: 'Joy', forWhom: '' },
];
const m = prayerMessage(open);
expect(m.title).toBe('Time to pray: 5 requests');
expect(m.body.split('\n')).toEqual([`• Mum: ${open[0].text.slice(0, 80)}…`, '• Wisdom at work', '• Farm: Rain', '…and 2 more']);
expect(prayerMessage([{ text: 'One', forWhom: '' }]).title).toBe('Time to pray: 1 request');
});
+32
View File
@@ -0,0 +1,32 @@
// Earlier versions of a study. Just before a save replaces what the server holds, that copy can be kept, so a
// bad merge or an accidental delete can be undone. To stay small, a copy is kept at most once per window of
// editing, it is compressed, and only the newest few are kept.
import { gunzipSync, gzipSync } from 'zlib';
export const MIN_GAP_MS = 10 * 60 * 1000; // one version per ten minutes of editing history
export const MAX_VERSIONS = 30;
// `prevEditedAt` is when the copy about to be replaced was saved; `lastKeptAt` is when the newest kept version was.
export function shouldSnapshot({ prevEditedAt, lastKeptAt, force = false, minGap = MIN_GAP_MS }) {
if (!Number.isFinite(prevEditedAt)) return false;
if (force) return true;
return !Number.isFinite(lastKeptAt) || prevEditedAt - lastKeptAt >= minGap;
}
export const pack = (json) => gzipSync(Buffer.from(typeof json === 'string' ? json : JSON.stringify(json)));
export const unpack = (buf) => JSON.parse(gunzipSync(buf).toString('utf8'));
const words = (s) => (String(s ?? '').trim() ? String(s).trim().split(/\s+/).length : 0);
// How big a study is: chunks and the words in its notes and scripts.
export function summarizeProject(project) {
let chunks = 0;
let noteWords = 0;
for (const chapter of project?.chapters ?? []) {
for (const chunk of chapter.chunks ?? []) {
chunks += 1;
for (const f of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) noteWords += words(chunk[f]);
}
}
return { chunks, words: noteWords };
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { MIN_GAP_MS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
test('a version is kept once per window of editing, and always the first', () => {
const t = 1_800_000_000_000;
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: undefined })).toBe(true);
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 60_000 })).toBe(false); // a minute after the last one
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - MIN_GAP_MS })).toBe(true);
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 1000, force: true })).toBe(true); // before a restore
expect(shouldSnapshot({ prevEditedAt: undefined, lastKeptAt: undefined })).toBe(false); // nothing to keep
});
test('a stored copy round-trips and is smaller than the original', () => {
const study = { id: 'a', title: 'T', chapters: [{ verses: Array.from({ length: 200 }, (_, i) => ({ number: i, text: 'In the beginning God created the heavens and the earth.' })) }] };
const packed = pack(study);
expect(packed.length).toBeLessThan(JSON.stringify(study).length / 5);
expect(unpack(packed)).toEqual(study);
});
test('a study is summarised by its chunks and the words in its notes', () => {
const s = summarizeProject({ chapters: [{ chunks: [{ observation: 'one two three', application: ' four ' }, { finalScript: 'a b' }] }, { chunks: [{}] }] });
expect(s).toEqual({ chunks: 3, words: 6 });
expect(summarizeProject(null)).toEqual({ chunks: 0, words: 0 });
});