Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices (union on first contact, newer server copy when this device is clean). Theme, default translation, verse layout, commentary source and reading position sync too, each with its own timestamp so the newest change to each setting wins. The Reader never moves under you while it is open. Signing out clears this device's marks and forgets sync timestamps. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
297f2c7e84
commit
760c1e568b
20 files changed
+976
-18
No files matched your search
@@ -97,6 +97,25 @@ export function initDb() {
|
||||
);
|
||||
`);
|
||||
|
||||
// Display preferences (theme, default translation, layout, ...) — one row per user.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
|
||||
// user across devices. updated_at lets a device tell whether the server copy is newer.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_marks (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reading plan progress — one row per user, holding the whole list of
|
||||
// in-progress/completed plans as JSON (small; no need to normalize).
|
||||
db.exec(`
|
||||
@@ -228,6 +247,39 @@ export function getAllReaderInk(userId) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
|
||||
export function getUserPrefs(userId) {
|
||||
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
|
||||
return row ? JSON.parse(row.data) : {};
|
||||
}
|
||||
|
||||
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
|
||||
export function setUserPrefs(userId, prefs) {
|
||||
db.prepare(`
|
||||
INSERT INTO user_prefs (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(prefs), Date.now());
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
const data = JSON.parse(row.data);
|
||||
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
|
||||
export function setReaderMarks(userId, { bookmarks, highlights }) {
|
||||
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);
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
|
||||
+47
-1
@@ -12,7 +12,7 @@ import {
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings,
|
||||
} from './db.js';
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from './auth.js';
|
||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
@@ -448,6 +450,50 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||
setUserPrefs(req.session.userId, merged);
|
||||
res.json({ prefs: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReaderMarks(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
const marks = cleanMarks(req.body);
|
||||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// A user's synced display preferences: theme, default Reader translation, verse layout, the
|
||||
// commentary they last chose, and where they left off in the Reader. Each preference carries
|
||||
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
|
||||
const ENUMS = {
|
||||
theme: ['paper', 'sepia', 'dark'],
|
||||
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
|
||||
layout: ['flow', 'verses'],
|
||||
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
|
||||
};
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
// A cleaned value, or undefined when it isn't acceptable for that preference.
|
||||
export function cleanPrefValue(key, value) {
|
||||
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
|
||||
if (key === 'position') {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const book = String(value.book ?? '').toUpperCase();
|
||||
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
|
||||
return { book, chapter: value.chapter };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
|
||||
export function cleanPrefsPatch(body, now = Date.now()) {
|
||||
const out = {};
|
||||
const prefs = body?.prefs;
|
||||
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
|
||||
for (const [key, entry] of Object.entries(prefs)) {
|
||||
const v = cleanPrefValue(key, entry?.v);
|
||||
const at = entry?.at;
|
||||
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
|
||||
out[key] = { v, at };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
|
||||
export function mergePrefs(existing = {}, patch = {}) {
|
||||
const merged = { ...existing };
|
||||
for (const [key, entry] of Object.entries(patch)) {
|
||||
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('only known preferences with valid values are accepted', () => {
|
||||
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
|
||||
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
|
||||
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
|
||||
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
|
||||
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
|
||||
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
|
||||
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
|
||||
});
|
||||
|
||||
test('position needs a real book and chapter, and the book is normalised', () => {
|
||||
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
|
||||
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
|
||||
const patch = cleanPrefsPatch({ prefs: {
|
||||
theme: { v: 'sepia', at: NOW },
|
||||
layout: { v: 'nope', at: NOW },
|
||||
evil: { v: 'x', at: NOW },
|
||||
translation: { v: 'NLT', at: 'yesterday' },
|
||||
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
|
||||
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
|
||||
} }, NOW);
|
||||
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
|
||||
expect(cleanPrefsPatch(null)).toEqual({});
|
||||
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
|
||||
});
|
||||
|
||||
test('the newest change to each preference wins, independently', () => {
|
||||
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
|
||||
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
|
||||
expect(mergePrefs(existing, patch)).toEqual({
|
||||
theme: { v: 'paper', at: 200 }, // patch is newer
|
||||
layout: { v: 'flow', at: 500 }, // stored is newer
|
||||
translation: { v: 'ESV', at: 50 }, // new key
|
||||
});
|
||||
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
|
||||
});
|
||||
|
||||
test('the database keeps one row per user', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
|
||||
const db = await import('./db.js');
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getUserPrefs('u1')).toEqual({});
|
||||
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
|
||||
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u2')).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
|
||||
// from the browser, so everything is checked and malformed entries are dropped.
|
||||
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
|
||||
const BOOK = /^[1-3A-Za-z]{3}$/;
|
||||
const MAX_BOOKMARKS = 5000;
|
||||
const MAX_HIGHLIGHTS = 20000;
|
||||
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
|
||||
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
|
||||
// not an object at all.
|
||||
export function cleanMarks(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||
|
||||
const bookmarks = {};
|
||||
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
|
||||
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
|
||||
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = [];
|
||||
if (Array.isArray(body.highlights)) {
|
||||
const seen = new Set();
|
||||
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
|
||||
if (!h || typeof h !== 'object') continue;
|
||||
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
|
||||
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
|
||||
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
|
||||
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
|
||||
seen.add(h.id);
|
||||
highlights.push({
|
||||
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
|
||||
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { bookmarks, highlights };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
|
||||
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
|
||||
|
||||
test('a non-object body is rejected', () => {
|
||||
expect(cleanMarks(null)).toBeNull();
|
||||
expect(cleanMarks([])).toBeNull();
|
||||
expect(cleanMarks('x')).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('malformed bookmarks are dropped, good ones kept', () => {
|
||||
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
|
||||
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
|
||||
});
|
||||
|
||||
test('highlights are validated field by field and de-duplicated by id', () => {
|
||||
const out = cleanMarks({ highlights: [
|
||||
hl(),
|
||||
hl({ id: 'h1', verse: 17 }), // duplicate id
|
||||
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
|
||||
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
|
||||
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
|
||||
'nope', null,
|
||||
hl({ id: 'ok', book: 'gen' }),
|
||||
] });
|
||||
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
|
||||
expect(out.highlights[1].book).toBe('GEN'); // normalised
|
||||
});
|
||||
|
||||
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
||||
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
|
||||
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
||||
});
|
||||
|
||||
test('the database keeps one row per user and updatedAt only moves forward', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
|
||||
const db = await import('./db.js');
|
||||
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 });
|
||||
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
||||
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
const saved = db.getReaderMarks('u1');
|
||||
expect(saved.updatedAt).toBe(t2);
|
||||
expect(saved.bookmarks).toEqual({});
|
||||
expect(saved.highlights).toHaveLength(1);
|
||||
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
||||
});
|
||||
Reference in new issue
Block a user