Sync bookmarks, highlights and display preferences to the account
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:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:45:23 -04:00
1 parent 297f2c7e84
commit 760c1e568b
20 files changed
+976 -18

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
// Bookmarks ({ "JHN-3-16": "#fef08a" }) and text highlights ([{ id, book, chapter, verse,
// startOffset, endOffset, color }]) — helpers for keeping them in sync between devices.
export const emptyMarks = () => ({ bookmarks: {}, highlights: [] });
export const isEmptyMarks = (m) => Object.keys(m.bookmarks).length === 0 && m.highlights.length === 0;
// Forgiving read of whatever came from localStorage or the server.
export function normalizeMarks(raw) {
const bookmarks = raw?.bookmarks && typeof raw.bookmarks === 'object' && !Array.isArray(raw.bookmarks) ? raw.bookmarks : {};
const highlights = Array.isArray(raw?.highlights) ? raw.highlights.filter((h) => h && typeof h.id === 'string') : [];
return { bookmarks, highlights };
}
const sameSpan = (a, b) => a.book === b.book && a.chapter === b.chapter && a.verse === b.verse
&& a.startOffset === b.startOffset && a.endOffset === b.endOffset;
// Union of two devices' marks. Used only when both changed since they last synced, or the
// first time a device meets the server's copy, so nothing made on either side is lost. Where the
// same bookmark has two colours, the server's wins.
export function mergeMarks(local, server) {
const bookmarks = { ...local.bookmarks, ...server.bookmarks };
const highlights = [...server.highlights];
for (const h of local.highlights) {
if (!highlights.some((s) => s.id === h.id || sameSpan(s, h))) highlights.push(h);
}
return { bookmarks, highlights };
}
export function marksEqual(a, b) {
const ka = Object.keys(a.bookmarks);
if (ka.length !== Object.keys(b.bookmarks).length) return false;
if (ka.some((k) => a.bookmarks[k] !== b.bookmarks[k])) return false;
if (a.highlights.length !== b.highlights.length) return false;
const byId = new Map(b.highlights.map((h) => [h.id, h]));
return a.highlights.every((h) => {
const o = byId.get(h.id);
return o && sameSpan(h, o) && h.color === o.color;
});
}
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from 'vitest';
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from './readerMarks.js';
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
test('normalizeMarks tolerates junk', () => {
expect(normalizeMarks(null)).toEqual(emptyMarks());
expect(normalizeMarks({ bookmarks: [], highlights: 'x' })).toEqual(emptyMarks());
expect(normalizeMarks({ bookmarks: { a: '#fff' }, highlights: [hl(), null, { nope: 1 }] }).highlights).toEqual([hl()]);
});
test('isEmptyMarks', () => {
expect(isEmptyMarks(emptyMarks())).toBe(true);
expect(isEmptyMarks({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [] })).toBe(false);
});
test('mergeMarks keeps everything from both devices; the server wins a colour clash', () => {
const local = { bookmarks: { 'JHN-3-16': '#111111', 'GEN-1-1': '#222222' }, highlights: [hl({ id: 'L' })] };
const server = { bookmarks: { 'JHN-3-16': '#999999', 'ROM-8-1': '#333333' }, highlights: [hl({ id: 'S', verse: 17 })] };
const merged = mergeMarks(local, server);
expect(merged.bookmarks).toEqual({ 'JHN-3-16': '#999999', 'GEN-1-1': '#222222', 'ROM-8-1': '#333333' });
expect(merged.highlights.map((h) => h.id).sort()).toEqual(['L', 'S']);
});
test('mergeMarks does not duplicate a highlight both devices made on the same words', () => {
const merged = mergeMarks({ bookmarks: {}, highlights: [hl({ id: 'local-id' })] }, { bookmarks: {}, highlights: [hl({ id: 'server-id' })] });
expect(merged.highlights).toHaveLength(1);
expect(merged.highlights[0].id).toBe('server-id');
});
test('marksEqual compares by value, not by reference or order', () => {
const a = { bookmarks: { x: '#1', y: '#2' }, highlights: [hl({ id: '1' }), hl({ id: '2', verse: 2 })] };
const b = { bookmarks: { y: '#2', x: '#1' }, highlights: [hl({ id: '2', verse: 2 }), hl({ id: '1' })] };
expect(marksEqual(a, b)).toBe(true);
expect(marksEqual(a, { ...b, bookmarks: { ...b.bookmarks, x: '#9' } })).toBe(false);
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1' })] })).toBe(false);
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1', color: '#000000' }), hl({ id: '2', verse: 2 })] })).toBe(false);
});