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
@@ -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