Books: Fortschritt-Sync robuster — Pending-Flag statt Timestamp (SW v13)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 18s

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 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-05-31 15:29:38 +02:00
parent b097eea600
commit 28756ffdc2
2 changed files with 68 additions and 28 deletions

View file

@ -2788,22 +2788,28 @@ async function _saveBookMeta(books) {
try { try {
const db = await _openBookCacheDb(); const db = await _openBookCacheDb();
const ids = new Set(books.map(b => String(b.id))); const ids = new Set(books.map(b => String(b.id)));
// Read existing local entries to detect offline-modified progress // Only load existing entries when there are books with pending offline progress
const existing = await new Promise(resolve => { 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(); 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.onsuccess = e => resolve(Object.fromEntries((e.target.result || []).map(b => [b.id, b])));
req.onerror = () => resolve({}); req.onerror = () => resolve({});
}); }) : {};
await new Promise(resolve => { await new Promise(resolve => {
const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_META_STORE); const store = tx.objectStore(_BOOK_META_STORE);
for (const b of books) { for (const b of books) {
if (pendingIds.has(String(b.id))) {
const loc = existing[b.id]; const loc = existing[b.id];
// Preserve local progress when it's newer than what the server returned if (loc) {
const localNewer = loc?.last_read && (!b.last_read || loc.last_read > b.last_read); // Keep local progress until it has been pushed to the server
store.put(localNewer store.put({...b, scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor, last_read: loc.last_read});
? {...b, scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor, last_read: loc.last_read} continue;
: b); }
}
store.put(b);
} }
store.openCursor().onsuccess = e => { store.openCursor().onsuccess = e => {
const cursor = e.target.result; const cursor = e.target.result;
@ -2874,6 +2880,7 @@ async function _loadAnnotationsCache(bookId, type) {
} }
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) { function _markPendingSync(bookId, type) {
try { try {
@ -2910,6 +2917,36 @@ async function _syncPendingAnnotations() {
} catch {} } 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 // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, fontFamily: 'serif', noBold: false,
@ -2995,22 +3032,22 @@ 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}); 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 // 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 _localMeta = await _loadCachedBookMeta();
const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b])); const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b]));
for (const b of decrypted) { for (const b of decrypted) {
if (_pendingIds.has(String(b.id))) {
const loc = _localById[b.id]; const loc = _localById[b.id];
if (loc?.last_read && (!b.last_read || loc.last_read > b.last_read)) { if (loc) {
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.scroll_fraction = loc.scroll_fraction;
b.position_anchor = loc.position_anchor; b.position_anchor = loc.position_anchor;
b.last_read = loc.last_read; b.last_read = loc.last_read;
} }
} }
}
}
decrypted.sort((a, b) => { decrypted.sort((a, b) => {
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
@ -3758,7 +3795,9 @@ async function saveReaderProgress() {
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: _lastProgressBeacon.body, body: _lastProgressBeacon.body,
}); });
} catch (e) {} } catch (e) {
if (!navigator.onLine) _markPendingProgress(currentBookId);
}
} }
function closeReader() { function closeReader() {
@ -5162,8 +5201,9 @@ function openRadioSidebar() {
}); });
window.addEventListener('online', async () => { window.addEventListener('online', async () => {
showOfflineBanner(false); showOfflineBanner(false);
await _syncPendingAnnotations(); // push offline bookmark/highlight changes first await _syncPendingAnnotations();
loadBookList(); // syncs progress and refreshes list await _syncPendingProgress();
loadBookList();
}); });
// Show banner immediately if already offline at startup // Show banner immediately if already offline at startup

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * 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 PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',