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
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:
1 parent
54cb1c217d
commit
207bfcb919
45 files changed
+1669
-75
No files matched your search
+116
-6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user