From b097eea600b84d4bd1162e984a607df2c8843e43 Mon Sep 17 00:00:00 2001 From: marwin Date: Sat, 30 May 2026 07:22:32 +0200 Subject: [PATCH] =?UTF-8?q?Books:=20offline-Fortschritt=20wird=20nicht=20?= =?UTF-8?q?=C3=BCberschrieben=20(SW=20v12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _saveBookMeta vergleicht local vs. Server last_read — neuere lokale Daten (offline gelesen) überleben den nächsten Server-Abruf - loadBookList pusht beim Online-Werden automatisch lokal neueren Fortschritt an den Server bevor die Liste gerendert wird - saveBookmarks/saveHighlights markieren fehlgeschlagene Saves als pending sync (localStorage); beim nächsten online-Event werden alle pending Annotationen zuerst gepusht, dann erst die Buchliste geladen Co-Authored-By: Claude Sonnet 4.6 --- static/js/app.js | 82 +++++++++++++++++++++++++++++++++++++++++++++--- static/js/sw.js | 2 +- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index ac1f457..35744f9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2788,10 +2788,23 @@ 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 => { + 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) store.put(b); + 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); + } store.openCursor().onsuccess = e => { const cursor = e.target.result; if (!cursor) return; @@ -2860,6 +2873,43 @@ async function _loadAnnotationsCache(bookId, type) { } catch { return null; } } +const _PENDING_SYNC_KEY = 'diora_pending_sync'; + +function _markPendingSync(bookId, type) { + try { + const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); + const key = `${bookId}:${type}`; + if (!pending.includes(key)) pending.push(key); + localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending)); + } catch {} +} + +async function _syncPendingAnnotations() { + try { + const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); + if (!pending.length) return; + const remaining = []; + for (const key of pending) { + const sep = key.indexOf(':'); + const bookId = key.slice(0, sep); + const type = key.slice(sep + 1); + const cached = await _loadAnnotationsCache(bookId, type); + if (!cached?.ct) continue; + const url = type === 'bookmarks' + ? `/books/${bookId}/bookmarks/` + : `/books/${bookId}/highlights/`; + try { + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ct: cached.ct, iv: cached.iv}), + }); + } catch { remaining.push(key); } + } + localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining)); + } catch {} +} + // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', fontFamily: 'serif', noBold: false, @@ -2945,6 +2995,23 @@ 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; + } + } + decrypted.sort((a, b) => { if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); if (a.last_read) return -1; @@ -4302,7 +4369,9 @@ async function saveBookmarks() { body, }); bookmarksDirty = false; - } catch (e) {} + } catch (e) { + _markPendingSync(currentBookId, 'bookmarks'); + } } function addBookmark() { @@ -4634,7 +4703,9 @@ async function saveHighlights() { body, }); highlightsDirty = false; - } catch (e) {} + } catch (e) { + _markPendingSync(currentBookId, 'highlights'); + } } let _highlightSaveDebounce = null; @@ -5089,9 +5160,10 @@ function openRadioSidebar() { showOfflineBanner(true); if (IS_AUTHENTICATED) showTab('books'); }); - window.addEventListener('online', () => { + window.addEventListener('online', async () => { showOfflineBanner(false); - loadBookList(); // sync any queued changes + await _syncPendingAnnotations(); // push offline bookmark/highlight changes first + loadBookList(); // syncs progress and refreshes list }); // Show banner immediately if already offline at startup diff --git a/static/js/sw.js b/static/js/sw.js index 8b05f8a..d6819b5 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-v11'; +const CACHE = 'diora-v12'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',