diff --git a/books/views.py b/books/views.py index a36797f..3a7ba80 100644 --- a/books/views.py +++ b/books/views.py @@ -16,6 +16,30 @@ def _require_auth(request): return None +def _anchor_parts(anchor): + """Split a position anchor 'blockIndex:innerFraction' into (block, inner). + + Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress).""" + if not isinstance(anchor, str) or ':' not in anchor: + return (-1, 0.0) + block, _, inner = anchor.partition(':') + try: + return (int(block), float(inner)) + except ValueError: + return (-1, 0.0) + + +def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac): + """True if the new reading position is at least as far into the book as the + old one. Compares by anchor block index (precise, decoupled from layout); + falls back to scroll_fraction only when an anchor is missing (PDFs).""" + nb, ni = _anchor_parts(new_anchor) + ob, oi = _anchor_parts(old_anchor) + if nb >= 0 and ob >= 0: + return ni >= oi if nb == ob else nb >= ob + return new_frac >= old_frac + + @require_http_methods(['GET']) def book_list(request): err = _require_auth(request) @@ -210,15 +234,13 @@ def save_progress(request, pk): user=request.user, book=book, ) - # Always advance to the furthest-read position unless the client - # explicitly forces a reset (e.g. "start over" button in the reader). - if force: + # Always advance to the furthest-read position (by anchor block index, so a + # transiently wrong scroll_fraction can't freeze the position) unless the + # client explicitly forces a reset (e.g. "start over" button in the reader). + if force or _progress_is_further(position_anchor, scroll_fraction, + progress.position_anchor, progress.scroll_fraction): 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 95d13cc..c4872b2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2699,6 +2699,26 @@ function restoreFromAnchor(contentEl, anchor) { return true; } +// Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty. +function _anchorParts(anchor) { + if (typeof anchor !== 'string') return [-1, 0]; + const c = anchor.indexOf(':'); + if (c < 0) return [-1, 0]; + const b = parseInt(anchor.slice(0, c), 10); + const f = parseFloat(anchor.slice(c + 1)); + return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0]; +} + +// >0 if position A is further into the book than B, <0 if behind, 0 if equal. +// Compares by anchor block index (precise); falls back to scroll_fraction only +// when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further. +function _cmpProgress(anchorA, fracA, anchorB, fracB) { + const [ba, ia] = _anchorParts(anchorA); + const [bb, ib] = _anchorParts(anchorB); + if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib; + return (fracA || 0) - (fracB || 0); +} + // --------------------------------------------------------------------------- // Book cache — IndexedDB, evict after 4 weeks of inactivity // --------------------------------------------------------------------------- @@ -2815,7 +2835,7 @@ async function _loadCachedBookMeta() { } catch { return []; } } -async function _updateCachedBookProgress(bookId, fraction, anchor) { +async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) { try { const db = await _openBookCacheDb(); await new Promise(resolve => { @@ -2824,7 +2844,8 @@ async function _updateCachedBookProgress(bookId, fraction, anchor) { const req = store.get(bookId); req.onsuccess = e => { const entry = e.target.result; - if (entry) { + // Only advance (or on force), so a transiently wrong save can't lose progress. + if (entry && (force || _cmpProgress(anchor, fraction, entry.position_anchor, entry.scroll_fraction) >= 0)) { entry.scroll_fraction = fraction; entry.position_anchor = anchor; entry.last_read = new Date().toISOString(); @@ -2988,7 +3009,7 @@ async function loadBookList() { 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) { + if (loc && _cmpProgress(loc.position_anchor, loc.scroll_fraction, b.position_anchor, b.scroll_fraction) > 0) { fetch(`/books/${b.id}/progress/`, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, @@ -3731,7 +3752,7 @@ async function saveReaderProgress(force = false) { _currentPositionAnchor = anchor; } - _updateCachedBookProgress(currentBookId, fraction, anchor); + _updateCachedBookProgress(currentBookId, fraction, anchor, force); const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})}); _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; diff --git a/static/js/sw.js b/static/js/sw.js index 2860d89..8f11f96 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-v14'; +const CACHE = 'diora-v15'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',