From 28756ffdc2a1c4c5f3e2d664a1c36c9728101428 Mon Sep 17 00:00:00 2001 From: marwin Date: Sun, 31 May 2026 15:29:38 +0200 Subject: [PATCH] =?UTF-8?q?Books:=20Fortschritt-Sync=20robuster=20?= =?UTF-8?q?=E2=80=94=20Pending-Flag=20statt=20Timestamp=20(SW=20v13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timestamp-Vergleich in _saveBookMeta entfernt: client-seitige ISO-Strings (new Date().toISOString) lagen fast immer leicht vor dem Serverwert und haben dadurch regelmässig korrekten Online-Fortschritt überschrieben. Neu: explizites Pending-Flag (localStorage diora_pending_progress) — gesetzt wenn saveReaderProgress offline scheitert, gelöscht nach erfolgreicher Synchronisierung. - _saveBookMeta nutzt das Flag statt Zeitstempel: Server-Daten überschreiben lokalen Fortschritt nur wenn kein Pending-Flag gesetzt - _syncPendingProgress: pusht ausstehenden Fortschritt beim online-Event bevor loadBookList läuft - saveReaderProgress setzt das Flag nur wenn !navigator.onLine Co-Authored-By: Claude Sonnet 4.6 --- static/js/app.js | 94 ++++++++++++++++++++++++++++++++++-------------- static/js/sw.js | 2 +- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 35744f9..9ad6f4e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2788,22 +2788,28 @@ async function _saveBookMeta(books) { try { const db = await _openBookCacheDb(); const ids = new Set(books.map(b => String(b.id))); - // Read existing local entries to detect offline-modified progress - const existing = await new Promise(resolve => { + // Only load existing entries when there are books with pending offline progress + const pendingIds = new Set( + JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]').map(String) + ); + const existing = pendingIds.size > 0 ? await new Promise(resolve => { const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll(); req.onsuccess = e => resolve(Object.fromEntries((e.target.result || []).map(b => [b.id, b]))); req.onerror = () => resolve({}); - }); + }) : {}; await new Promise(resolve => { const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_META_STORE); for (const b of books) { - const loc = existing[b.id]; - // Preserve local progress when it's newer than what the server returned - const localNewer = loc?.last_read && (!b.last_read || loc.last_read > b.last_read); - store.put(localNewer - ? {...b, scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor, last_read: loc.last_read} - : b); + if (pendingIds.has(String(b.id))) { + const loc = existing[b.id]; + if (loc) { + // Keep local progress until it has been pushed to the server + store.put({...b, scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor, last_read: loc.last_read}); + continue; + } + } + store.put(b); } store.openCursor().onsuccess = e => { const cursor = e.target.result; @@ -2873,7 +2879,8 @@ async function _loadAnnotationsCache(bookId, type) { } catch { return null; } } -const _PENDING_SYNC_KEY = 'diora_pending_sync'; +const _PENDING_SYNC_KEY = 'diora_pending_sync'; +const _PENDING_PROGRESS_KEY = 'diora_pending_progress'; function _markPendingSync(bookId, type) { try { @@ -2910,6 +2917,36 @@ async function _syncPendingAnnotations() { } catch {} } +function _markPendingProgress(bookId) { + try { + const pending = new Set(JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]').map(String)); + pending.add(String(bookId)); + localStorage.setItem(_PENDING_PROGRESS_KEY, JSON.stringify([...pending])); + } catch {} +} + +async function _syncPendingProgress() { + try { + const pending = JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]'); + if (!pending.length) return; + const localMeta = await _loadCachedBookMeta(); + const localById = Object.fromEntries(localMeta.map(b => [b.id, b])); + const remaining = []; + for (const bookId of pending) { + const loc = localById[bookId]; + if (!loc) continue; + try { + await fetch(`/books/${bookId}/progress/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}), + }); + } catch { remaining.push(bookId); } + } + localStorage.setItem(_PENDING_PROGRESS_KEY, JSON.stringify(remaining)); + } catch {} +} + // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', fontFamily: 'serif', noBold: false, @@ -2995,20 +3032,20 @@ async function loadBookList() { decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false}); } } - // Push any locally-cached progress that's newer than the server returned - const _localMeta = await _loadCachedBookMeta(); - const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b])); - for (const b of decrypted) { - const loc = _localById[b.id]; - if (loc?.last_read && (!b.last_read || loc.last_read > b.last_read)) { - fetch(`/books/${b.id}/progress/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}), - }).catch(() => {}); - b.scroll_fraction = loc.scroll_fraction; - b.position_anchor = loc.position_anchor; - b.last_read = loc.last_read; + // Use local progress for books that have unsaved offline changes + const _pendingIds = new Set(JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]').map(String)); + if (_pendingIds.size > 0) { + const _localMeta = await _loadCachedBookMeta(); + const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b])); + for (const b of decrypted) { + if (_pendingIds.has(String(b.id))) { + const loc = _localById[b.id]; + if (loc) { + b.scroll_fraction = loc.scroll_fraction; + b.position_anchor = loc.position_anchor; + b.last_read = loc.last_read; + } + } } } @@ -3758,7 +3795,9 @@ async function saveReaderProgress() { headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: _lastProgressBeacon.body, }); - } catch (e) {} + } catch (e) { + if (!navigator.onLine) _markPendingProgress(currentBookId); + } } function closeReader() { @@ -5162,8 +5201,9 @@ function openRadioSidebar() { }); window.addEventListener('online', async () => { showOfflineBanner(false); - await _syncPendingAnnotations(); // push offline bookmark/highlight changes first - loadBookList(); // syncs progress and refreshes list + await _syncPendingAnnotations(); + await _syncPendingProgress(); + loadBookList(); }); // Show banner immediately if already offline at startup diff --git a/static/js/sw.js b/static/js/sw.js index d6819b5..9d6b9c6 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v12'; +const CACHE = 'diora-v13'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',