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
+8
-1
@@ -62,8 +62,14 @@ const App = () => {
|
||||
// a failed GET via the same error banner a failed push uses, instead
|
||||
// of silently leaving stale/empty local state in place.
|
||||
readingPlans.pullServerReadingProgress();
|
||||
// Bookmarks and highlights: reconcile this device's copy with the account's.
|
||||
reader.pullServerMarks();
|
||||
// Theme, default translation, layout, commentary and reading position.
|
||||
reader.pullServerPrefs();
|
||||
},
|
||||
onSignOut: () => {
|
||||
reader.stopMarksSync();
|
||||
reader.stopPrefsSync();
|
||||
setProjectIndex(switchStorageUser(null));
|
||||
goHome();
|
||||
},
|
||||
@@ -249,7 +255,8 @@ const App = () => {
|
||||
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
||||
authUser, setAuthUser,
|
||||
signIn,
|
||||
logout,
|
||||
// Save any pending bookmarks/highlights to the account before signing out
|
||||
logout: async () => { await reader.flushMarks(); await reader.flushPrefs(); await logout(); },
|
||||
// Save / sync status (shown in the stage bar)
|
||||
saveStatus, setSaveStatus,
|
||||
syncStatus,
|
||||
|
||||
@@ -966,3 +966,175 @@ describe('Reading plan with more days than chapters', () => {
|
||||
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bookmarks and highlights follow the account
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Bookmark and highlight sync', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over });
|
||||
|
||||
// A fake API that keeps the account's marks, like the real server does.
|
||||
function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) {
|
||||
const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] };
|
||||
const fallback = buildFetchMock();
|
||||
const fetchMock = vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||
if (route === 'PUT /api/prefs') {
|
||||
const patch = JSON.parse(opts.body).prefs;
|
||||
server.prefPuts.push(patch);
|
||||
server.prefs = { ...server.prefs, ...patch };
|
||||
return json(200, { prefs: server.prefs });
|
||||
}
|
||||
if (route === 'PUT /api/reader/marks') {
|
||||
const body = JSON.parse(opts.body);
|
||||
server.puts.push(body);
|
||||
server.bookmarks = body.bookmarks;
|
||||
server.highlights = body.highlights;
|
||||
server.updatedAt = Math.max(Date.now(), server.updatedAt + 1);
|
||||
return json(200, { ok: true, updatedAt: server.updatedAt });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return server;
|
||||
}
|
||||
const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null');
|
||||
|
||||
test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => {
|
||||
stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||
expect(local('readerTextHighlights')).toEqual([hl()]);
|
||||
});
|
||||
|
||||
test('marks already on this device are saved to an account that has none yet', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
const server = stubServer();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' });
|
||||
});
|
||||
|
||||
test('marks on both sides are merged, and the merge is saved back', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||
expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||
});
|
||||
|
||||
test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
localStorage.setItem('reader-marks-synced-at', '100');
|
||||
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({}));
|
||||
expect(server.puts).toHaveLength(0); // nothing to send back
|
||||
});
|
||||
|
||||
test('bookmarking a verse in the Reader saves it to the account', async () => {
|
||||
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 });
|
||||
localStorage.setItem('reader-marks-synced-at', '500');
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
await u.click(await screen.findByText(/Paul, a servant of God/));
|
||||
await u.click(await screen.findByRole('button', { name: /bookmark verse/i }));
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||
expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']);
|
||||
});
|
||||
|
||||
test('signing out clears this device\'s copy so the next person starts clean', async () => {
|
||||
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||
await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull());
|
||||
expect(local('reader-bookmarks')).toEqual({});
|
||||
expect(local('readerTextHighlights')).toEqual([]);
|
||||
expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preference sync', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
function stubPrefsServer(prefs = {}) {
|
||||
const server = { prefs, puts: [] };
|
||||
const fallback = buildFetchMock();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||
if (route === 'PUT /api/prefs') {
|
||||
const patch = JSON.parse(opts.body).prefs;
|
||||
server.puts.push(patch);
|
||||
server.prefs = { ...server.prefs, ...patch };
|
||||
return json(200, { prefs: server.prefs });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
return server;
|
||||
}
|
||||
|
||||
test('signing in on a new device brings your theme, translation and place in the Reader', async () => {
|
||||
stubPrefsServer({
|
||||
theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 },
|
||||
position: { v: { book: 'JHN', chapter: 3 }, at: 900 },
|
||||
});
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark'));
|
||||
expect(localStorage.getItem('reader-translation')).toBe('NASB');
|
||||
expect(localStorage.getItem('reader-layout')).toBe('verses');
|
||||
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('changing the theme is saved to the account', async () => {
|
||||
const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys
|
||||
server.puts.length = 0;
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(screen.getByRole('button', { name: /^sepia$/i }));
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||
expect(server.puts[0].theme.v).toBe('sepia');
|
||||
expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed
|
||||
});
|
||||
|
||||
test('the commentary you pick is shared between the Reader and the Study stage', async () => {
|
||||
stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
|
||||
});
|
||||
|
||||
test('signing out forgets the sync timestamps', async () => {
|
||||
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull());
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,18 @@
|
||||
// Inline study panel shown under a verse in the Reader: commentary from several
|
||||
// public-domain / open sources, plus the people, places and events tied to the verse.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import {
|
||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||
} from '../hooks/useVerseStudy.js';
|
||||
|
||||
export function CommentaryTab({ book, chapter, verse }) {
|
||||
const [sourceId, setSourceId] = useState(() => {
|
||||
try { return localStorage.getItem('reader-commentary') || COMMENTARIES[0].id; } catch { return COMMENTARIES[0].id; }
|
||||
});
|
||||
// The chosen commentary is a synced preference, shared with the Study stage
|
||||
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
|
||||
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
|
||||
const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
|
||||
const entry = pickCommentaryEntry(data, verse);
|
||||
|
||||
const changeSource = (id) => {
|
||||
setSourceId(id);
|
||||
try { localStorage.setItem('reader-commentary', id); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-[var(--r-sub,#64748b)]">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
// the same <audio> element.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { useReaderMarksSync } from './useReaderMarksSync.js';
|
||||
import { usePrefsSync } from './usePrefsSync.js';
|
||||
import { COMMENTARIES } from './useVerseStudy.js';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
export const READER_TRANSLATIONS = [
|
||||
@@ -24,8 +27,26 @@ const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
|
||||
// open; otherwise the reader's own (remembered) translation is used.
|
||||
export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
// Where you left off (saved on this device, and synced to the account)
|
||||
const [savedPosition] = useState(() => {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
|
||||
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
|
||||
} catch { return null; }
|
||||
});
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
|
||||
const [readerCommentary, setReaderCommentaryState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('reader-commentary');
|
||||
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
|
||||
} catch { return COMMENTARIES[0].id; }
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
|
||||
}, [readerBookAbbrev, readerChapter]);
|
||||
const pageRef = useRef(currentPage);
|
||||
pageRef.current = currentPage;
|
||||
const [baseTranslation, setReaderTranslationState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('reader-translation');
|
||||
@@ -138,6 +159,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
try { localStorage.setItem('reader-layout', id); } catch {}
|
||||
};
|
||||
|
||||
const setReaderCommentary = (id) => {
|
||||
setReaderCommentaryState(id);
|
||||
try { localStorage.setItem('reader-commentary', id); } catch {}
|
||||
};
|
||||
|
||||
const setReaderTranslation = (id) => {
|
||||
setReaderTranslationState(id);
|
||||
try { localStorage.setItem('reader-translation', id); } catch {}
|
||||
@@ -361,8 +387,31 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Bookmarks and highlights follow the signed-in user across devices.
|
||||
const marksSync = useReaderMarksSync({
|
||||
bookmarks: readerBookmarks, highlights: readerTextHighlights,
|
||||
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
|
||||
});
|
||||
|
||||
// Theme, default translation, layout, commentary and reading position follow the account too.
|
||||
const prefsSync = usePrefsSync({
|
||||
values: {
|
||||
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
|
||||
position: { book: readerBookAbbrev, chapter: readerChapter },
|
||||
},
|
||||
apply: (key, v) => {
|
||||
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
|
||||
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
|
||||
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
|
||||
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
|
||||
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
|
||||
},
|
||||
canApplyPosition: () => pageRef.current !== 'reader',
|
||||
});
|
||||
|
||||
return {
|
||||
readerBookAbbrev, setReaderBookAbbrev,
|
||||
readerCommentary, setReaderCommentary,
|
||||
readerChapter, setReaderChapter,
|
||||
readerTranslation, setReaderTranslation,
|
||||
readerCopyright,
|
||||
@@ -387,6 +436,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
updateReaderPageInk,
|
||||
mergeServerInk,
|
||||
readerTextHighlights,
|
||||
...marksSync,
|
||||
...prefsSync,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
bibleIndexStatusFor,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Keeps a few display preferences (theme, default translation, verse layout, commentary
|
||||
// source, and where you left off in the Reader) in step with the signed-in account.
|
||||
// Every preference has its own timestamp, so the newest change to each one wins and two
|
||||
// devices changing different settings never overwrite each other.
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
|
||||
|
||||
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'position'];
|
||||
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
|
||||
const PUSH_DELAY_MS = 1000;
|
||||
const FOCUS_PULL_MIN_MS = 30_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 */ } };
|
||||
const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } };
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
// values: { theme, translation, layout, commentarySource, position: { book, chapter } }
|
||||
// apply(key, value): puts a server value on screen and in localStorage
|
||||
// canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you
|
||||
export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
|
||||
const valuesRef = useRef(values);
|
||||
valuesRef.current = values;
|
||||
const prevRef = useRef(values);
|
||||
const enabledRef = useRef(false);
|
||||
const dirtyRef = useRef(new Set());
|
||||
const timerRef = useRef(null);
|
||||
const lastPullRef = useRef(0);
|
||||
const applyRef = useRef(apply);
|
||||
applyRef.current = apply;
|
||||
const canApplyPositionRef = useRef(canApplyPosition);
|
||||
canApplyPositionRef.current = canApplyPosition;
|
||||
|
||||
const push = useCallback(async () => {
|
||||
window.clearTimeout(timerRef.current);
|
||||
if (!enabledRef.current || dirtyRef.current.size === 0) return;
|
||||
const keys = [...dirtyRef.current];
|
||||
const meta = readMeta();
|
||||
const prefs = Object.fromEntries(keys.map((k) => [k, { v: valuesRef.current[k], at: meta[k] ?? Date.now() }]));
|
||||
const result = await saveRemotePrefs(prefs);
|
||||
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
|
||||
keys.forEach((k) => { if (readMeta()[k] === prefs[k].at) dirtyRef.current.delete(k); });
|
||||
if (dirtyRef.current.size > 0) timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
// Local changes: stamp them and save shortly.
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current;
|
||||
const changed = PREF_KEYS.filter((k) => !same(prev[k], values[k]));
|
||||
prevRef.current = values;
|
||||
if (changed.length === 0) return;
|
||||
const meta = readMeta();
|
||||
const now = Date.now();
|
||||
changed.forEach((k) => { meta[k] = now; dirtyRef.current.add(k); });
|
||||
lsSet(META_KEY, JSON.stringify(meta));
|
||||
if (enabledRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||
}
|
||||
}, [values.theme, values.translation, values.layout, values.commentarySource, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const pullServerPrefs = useCallback(async () => {
|
||||
enabledRef.current = true;
|
||||
lastPullRef.current = Date.now();
|
||||
const result = await getRemotePrefs();
|
||||
if (!result.ok) return;
|
||||
const server = result.data?.prefs ?? {};
|
||||
const meta = readMeta();
|
||||
const now = Date.now();
|
||||
for (const key of PREF_KEYS) {
|
||||
const remote = server[key];
|
||||
const localAt = meta[key] ?? 0;
|
||||
if (remote && remote.at > localAt) {
|
||||
if (key === 'position' && !canApplyPositionRef.current()) continue; // don't move the page mid-read
|
||||
meta[key] = remote.at;
|
||||
if (!same(valuesRef.current[key], remote.v)) {
|
||||
prevRef.current = { ...prevRef.current, [key]: remote.v }; // so this isn't mistaken for a local edit
|
||||
applyRef.current(key, remote.v);
|
||||
}
|
||||
dirtyRef.current.delete(key);
|
||||
} else if (!remote || localAt > remote.at) {
|
||||
if (!meta[key]) meta[key] = now; // first time on this account: what's on screen becomes the account's
|
||||
dirtyRef.current.add(key);
|
||||
}
|
||||
}
|
||||
lsSet(META_KEY, JSON.stringify(meta));
|
||||
await push();
|
||||
}, [push]);
|
||||
|
||||
// Signed out: stop syncing. Values stay as they are on this device; the timestamps are
|
||||
// forgotten so the next account to sign in here starts from its own settings.
|
||||
const stopPrefsSync = useCallback(() => {
|
||||
enabledRef.current = false;
|
||||
window.clearTimeout(timerRef.current);
|
||||
dirtyRef.current.clear();
|
||||
lsDel(META_KEY);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (!enabledRef.current) return;
|
||||
if (document.visibilityState === 'hidden') { push(); return; }
|
||||
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerPrefs();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||
}, [push, pullServerPrefs]);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||
|
||||
return { pullServerPrefs, stopPrefsSync, flushPrefs: push };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
const remote = { prefs: {}, puts: [] };
|
||||
vi.mock('../syncService.js', () => ({
|
||||
getRemotePrefs: vi.fn(async () => ({ ok: true, data: { prefs: remote.prefs } })),
|
||||
saveRemotePrefs: vi.fn(async (prefs) => { remote.puts.push(prefs); return { ok: true, data: { prefs } }; }),
|
||||
}));
|
||||
|
||||
import { usePrefsSync } from './usePrefsSync.js';
|
||||
|
||||
beforeEach(() => { localStorage.clear(); remote.prefs = {}; remote.puts = []; });
|
||||
|
||||
// A stand-in for the Reader's state, wired the same way useBibleReader wires it.
|
||||
function setup({ canApplyPosition = () => true, initial = {} } = {}) {
|
||||
return renderHook(() => {
|
||||
const [theme, setTheme] = useState(initial.theme ?? 'paper');
|
||||
const [layout, setLayout] = useState(initial.layout ?? 'flow');
|
||||
const [position, setPosition] = useState(initial.position ?? { book: 'GEN', chapter: 1 });
|
||||
const sync = usePrefsSync({
|
||||
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', position },
|
||||
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
|
||||
canApplyPosition,
|
||||
});
|
||||
return { theme, layout, position, setTheme, setLayout, setPosition, ...sync };
|
||||
});
|
||||
}
|
||||
|
||||
test('the server\'s newer value wins, an older one does not', async () => {
|
||||
localStorage.setItem('prefs-meta', JSON.stringify({ theme: 500, layout: 900 }));
|
||||
remote.prefs = { theme: { v: 'dark', at: 800 }, layout: { v: 'verses', at: 100 } };
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.theme).toBe('dark'); // server is newer
|
||||
expect(h.result.current.layout).toBe('flow'); // this device changed it later
|
||||
expect(remote.puts).toHaveLength(1);
|
||||
expect(Object.keys(remote.puts[0])).toContain('layout'); // ...so the server hears about it
|
||||
expect(Object.keys(remote.puts[0])).not.toContain('theme');
|
||||
});
|
||||
|
||||
test('a device with no history takes the account\'s settings; a first device uploads its own', async () => {
|
||||
remote.prefs = { theme: { v: 'sepia', at: 700 } };
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.theme).toBe('sepia');
|
||||
// keys the account doesn't have yet are filled in from this device
|
||||
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'position', 'translation']);
|
||||
});
|
||||
|
||||
test('reading position is not moved under you while the Reader is open', async () => {
|
||||
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 }, theme: { v: 'dark', at: 900 } };
|
||||
const h = setup({ canApplyPosition: () => false });
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.position).toEqual({ book: 'GEN', chapter: 1 }); // stays put
|
||||
expect(h.result.current.theme).toBe('dark'); // everything else still syncs
|
||||
});
|
||||
|
||||
test('and it is applied when the Reader is not open', async () => {
|
||||
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 } };
|
||||
const h = setup({ canApplyPosition: () => true });
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.position).toEqual({ book: 'JHN', chapter: 3 });
|
||||
expect(remote.puts.every((p) => !('position' in p))).toBe(true); // applying it isn't mistaken for a local edit
|
||||
});
|
||||
|
||||
test('a local change is stamped and pushed shortly after', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
remote.puts = [];
|
||||
act(() => h.result.current.setTheme('dark'));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||
expect(remote.puts).toHaveLength(1);
|
||||
expect(remote.puts[0].theme.v).toBe('dark');
|
||||
expect(JSON.parse(localStorage.getItem('prefs-meta')).theme).toBe(remote.puts[0].theme.at);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
test('stopping sync forgets the timestamps so the next account starts from its own settings', async () => {
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(localStorage.getItem('prefs-meta')).not.toBeNull();
|
||||
act(() => h.result.current.stopPrefsSync());
|
||||
expect(localStorage.getItem('prefs-meta')).toBeNull();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { useEffect, useState } from 'react';
|
||||
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
|
||||
const interlinearCache = {}; // BOOK -> whole-book interlinear data
|
||||
|
||||
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
|
||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||
// `commentarySource` / `setCommentarySource` may be passed in to share the choice with the rest of
|
||||
// the app (it's a synced preference); otherwise the hook keeps its own.
|
||||
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections, commentarySource: sharedSource, setCommentarySource: setSharedSource }) {
|
||||
const [ownSource, setOwnSource] = useState('matthew-henry');
|
||||
const commentarySource = sharedSource ?? ownSource;
|
||||
const setCommentarySource = setSharedSource ?? setOwnSource;
|
||||
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
|
||||
const [commentaryLoading, setCommentaryLoading] = useState(false);
|
||||
const [commentaryError, setCommentaryError] = useState('');
|
||||
|
||||
@@ -6,7 +6,7 @@ const ctx = {
|
||||
goHome: noop,
|
||||
readerBookAbbrev: 'JHN', setReaderBookAbbrev: noop,
|
||||
readerTranslation: 'ESV', setReaderTranslation: noop,
|
||||
readerTheme: 'paper', setReaderTheme: noop,
|
||||
readerTheme: 'paper', setReaderTheme: noop, readerCommentary: 'matthew-henry', setReaderCommentary: noop,
|
||||
readerLayout: 'flow', setReaderLayout: noop,
|
||||
readerChapter: 3, setReaderChapter: noop,
|
||||
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
|
||||
|
||||
@@ -264,8 +264,7 @@ export default function SettingsPage() {
|
||||
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
|
||||
<p className="text-sm text-[var(--r-sub)]">
|
||||
A document of your bookmarked verses, highlighted passages, and completed reading plans.
|
||||
Bookmarks and highlights only live on this device (they're not synced), so this reflects
|
||||
what's saved here.
|
||||
Bookmarks and highlights are saved to your account and follow you between devices.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -15,7 +15,7 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
|
||||
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
|
||||
|
||||
export default function StudyPage() {
|
||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage } = useApp();
|
||||
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary } = useApp();
|
||||
|
||||
// Short-lived status line (word suggestions, lookups, …)
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
@@ -40,6 +40,7 @@ export default function StudyPage() {
|
||||
const resources = useStudyResources({
|
||||
project, selectedChunk, selectedChunkChapterIndex: chapterIndex,
|
||||
collapsedSections: { commentary: tab !== 'commentary', interlinear: false },
|
||||
commentarySource: readerCommentary, setCommentarySource: setReaderCommentary,
|
||||
});
|
||||
const crossRefs = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
|
||||
const wordStudy = useWordStudy({ project, allChunks, updateProject, notify });
|
||||
|
||||
@@ -159,6 +159,26 @@ export async function changePassword(currentPassword, newPassword) {
|
||||
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
/** The signed-in user's synced display preferences: { prefs: { key: { v, at } } }. */
|
||||
export async function getRemotePrefs() {
|
||||
return request('GET', '/prefs');
|
||||
}
|
||||
|
||||
/** Merges changed preferences into the account ({ key: { v, at } }); newest change per key wins. */
|
||||
export async function saveRemotePrefs(prefs) {
|
||||
return request('PUT', '/prefs', { prefs });
|
||||
}
|
||||
|
||||
/** The signed-in user's bookmarks + highlights: { bookmarks, highlights, updatedAt }. */
|
||||
export async function getRemoteMarks() {
|
||||
return request('GET', '/reader/marks');
|
||||
}
|
||||
|
||||
/** Replaces the user's bookmarks + highlights ({ bookmarks, highlights }); returns { updatedAt }. */
|
||||
export async function saveRemoteMarks(marks) {
|
||||
return request('PUT', '/reader/marks', marks);
|
||||
}
|
||||
|
||||
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
|
||||
export async function sendReminderTest(settings) {
|
||||
return request('POST', '/auth/reminder-test', settings);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user