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

+124
View File
@@ -0,0 +1,124 @@
// Keeps the Reader's bookmarks and text highlights in step with the signed-in user's
// account, so they follow you between devices. Local edits save to localStorage as they
// always did; while signed in they are also pushed to the server a moment after the last
// change, and pulled when you sign in and whenever you come back to the tab.
import { useCallback, useEffect, useRef } from 'react';
import { getRemoteMarks, saveRemoteMarks } from '../syncService.js';
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
const PUSH_DELAY_MS = 1500;
const FOCUS_PULL_MIN_MS = 20_000;
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) {
const enabledRef = useRef(false);
const latestRef = useRef({ bookmarks, highlights });
latestRef.current = { bookmarks, highlights };
const timerRef = useRef(null);
const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit
const firstRunRef = useRef(true);
const lastPullRef = useRef(0);
const pushingRef = useRef(null);
const setDirty = (dirty) => (dirty ? lsSet(KEYS.dirty, '1') : lsDel(KEYS.dirty));
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
if (pushingRef.current) { await pushingRef.current; }
const snapshot = latestRef.current;
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
const result = await pushingRef.current;
pushingRef.current = null;
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
lsSet(KEYS.syncedAt, String(result.data.updatedAt));
// Only clean if nothing changed while the request was in flight
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false);
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}, []);
// Local edits: remember there's something to save, and save it shortly.
useEffect(() => {
if (firstRunRef.current) { firstRunRef.current = false; return; }
if (skipRef.current) { skipRef.current = false; return; }
setDirty(true);
if (enabledRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [bookmarks, highlights, push]);
// Puts `next` on screen and in localStorage without treating it as a local edit.
const apply = useCallback((next) => {
if (marksEqual(next, latestRef.current)) return;
skipRef.current = true;
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
setBookmarks(next.bookmarks);
setHighlights(next.highlights);
}, [setBookmarks, setHighlights]);
// Sign-in (and tab focus): reconcile this device with the server's copy.
const pullServerMarks = useCallback(async () => {
enabledRef.current = true;
lastPullRef.current = Date.now();
const result = await getRemoteMarks();
if (!result.ok) return;
const server = normalizeMarks(result.data);
const serverAt = Number(result.data?.updatedAt) || 0;
const local = normalizeMarks(latestRef.current);
const syncedAt = Number(lsGet(KEYS.syncedAt)) || 0;
const dirty = lsGet(KEYS.dirty) === '1';
let next = local;
let needsPush = false;
if (serverAt === 0) {
needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here
} else if (syncedAt === 0) {
next = mergeMarks(local, server); // first time this device meets the server's copy: keep both
needsPush = !marksEqual(next, server);
} else if (serverAt > syncedAt) {
next = dirty ? mergeMarks(local, server) : server; // another device saved since we last synced
needsPush = dirty && !marksEqual(next, server);
} else {
needsPush = dirty; // server has nothing newer than what we already have
}
apply(next);
if (serverAt > 0) lsSet(KEYS.syncedAt, String(serverAt));
if (needsPush) { setDirty(true); latestRef.current = next; await push(); } else if (serverAt >= syncedAt) setDirty(false);
}, [apply, push]);
// Save anything pending before signing out.
const flushMarks = useCallback(async () => {
if (enabledRef.current && lsGet(KEYS.dirty) === '1') await push();
}, [push]);
// Signed out: stop syncing and clear this device's copy, so the next person to sign in
// here starts clean (their marks come from their own account).
const stopMarksSync = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
apply(emptyMarks());
}, [apply]);
// Coming back to the tab (another device may have saved), or leaving it (save now).
useEffect(() => {
const onVisibility = () => {
if (!enabledRef.current) return;
if (document.visibilityState === 'hidden') { if (lsGet(KEYS.dirty) === '1') push(); return; }
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerMarks();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, [push, pullServerMarks]);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { pullServerMarks, flushMarks, stopMarksSync };
}