diff --git a/books/views.py b/books/views.py index c1322ae..a36797f 100644 --- a/books/views.py +++ b/books/views.py @@ -204,12 +204,21 @@ def save_progress(request, pk): if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor): position_anchor = raw_anchor + force = bool(body.get('force', False)) + progress, _ = EBookProgress.objects.get_or_create( user=request.user, book=book, ) - progress.scroll_fraction = scroll_fraction - progress.position_anchor = position_anchor + # Always advance to the furthest-read position unless the client + # explicitly forces a reset (e.g. "start over" button in the reader). + if force: + progress.scroll_fraction = scroll_fraction + progress.position_anchor = position_anchor + else: + if scroll_fraction >= progress.scroll_fraction: + progress.scroll_fraction = scroll_fraction + progress.position_anchor = position_anchor progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at']) return JsonResponse({'ok': True}) diff --git a/static/js/app.js b/static/js/app.js index 9ad6f4e..95d13cc 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2788,29 +2788,10 @@ async function _saveBookMeta(books) { try { const db = await _openBookCacheDb(); const ids = new Set(books.map(b => String(b.id))); - // 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) { - 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); - } + for (const b of books) store.put(b); store.openCursor().onsuccess = e => { const cursor = e.target.result; if (!cursor) return; @@ -2879,8 +2860,7 @@ async function _loadAnnotationsCache(bookId, type) { } catch { return null; } } -const _PENDING_SYNC_KEY = 'diora_pending_sync'; -const _PENDING_PROGRESS_KEY = 'diora_pending_progress'; +const _PENDING_SYNC_KEY = 'diora_pending_sync'; function _markPendingSync(bookId, type) { try { @@ -2917,36 +2897,6 @@ 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, @@ -3032,20 +2982,21 @@ 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}); } } - // 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; - } - } + // If local cache is further ahead than the server, push it and use it for display. + // Server uses max-semantics so this is always safe. + 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 && loc.scroll_fraction > b.scroll_fraction) { + 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; } } @@ -3761,7 +3712,7 @@ async function deleteBook(bookId) { } catch (e) {} } -async function saveReaderProgress() { +async function saveReaderProgress(force = false) { if (!currentBookId) return; const contentEl = $('reader-content'); if (!contentEl) return; @@ -3780,24 +3731,25 @@ async function saveReaderProgress() { _currentPositionAnchor = anchor; } - // Always update local cache so offline re-opens land at the right position _updateCachedBookProgress(currentBookId, fraction, anchor); - // Cache for sendBeacon on unload - _lastProgressBeacon = { - url: `/books/${currentBookId}/progress/`, - body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}), - }; + const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})}); + _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; try { await fetch(`/books/${currentBookId}/progress/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: _lastProgressBeacon.body, + body, }); - } catch (e) { - if (!navigator.onLine) _markPendingProgress(currentBookId); - } + if (force) { + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = 'Position gesetzt'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 1800); + } + } catch (e) {} } function closeReader() { @@ -5202,8 +5154,7 @@ function openRadioSidebar() { window.addEventListener('online', async () => { showOfflineBanner(false); await _syncPendingAnnotations(); - await _syncPendingProgress(); - loadBookList(); + loadBookList(); // loadBookList pushes any locally-advanced progress automatically }); // Show banner immediately if already offline at startup diff --git a/static/js/sw.js b/static/js/sw.js index 9d6b9c6..2860d89 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-v13'; +const CACHE = 'diora-v14'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index 79d6d24..76fba67 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -338,11 +338,12 @@ - - - - - + + + + + +