Books: Fortschritt-Konflikt via Anker-Blockindex statt scroll_fraction (SW v15)
All checks were successful
Build and push Docker image / build (push) Successful in 1m1s
Test / test (push) Successful in 15s

Die max()-Semantik aus v14 verglich nur die grobe scroll_fraction. Da
scroll_fraction und position_anchor gemeinsam, aber entkoppelt gespeichert
werden, konnte ein einzelner Save mit transient zu hoher fraction (z.B.
instabiles Layout beim Laden) die Position dauerhaft einfrieren: jeder
spätere echte Fortschritt hatte eine kleinere fraction und wurde inklusive
Anker verworfen — der Leser landete immer wieder an derselben Stelle.

Fix: Vergleich läuft jetzt über den Blockindex im Anker (die Größe, die
auch die Sprungposition bestimmt), mit innerFraction als Tiebreaker.
scroll_fraction dient nur noch als Fallback für PDFs ohne Anker. Server
(_progress_is_further) und Client (_cmpProgress) nutzen identische Logik;
loadBookList-Push und lokaler Cache ebenfalls. Bestehende eingefrorene
Einträge entfrieren sich beim ersten Weiterlesen selbst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-07-10 20:52:27 +02:00
parent 29f6cc4413
commit daffd0002b
3 changed files with 55 additions and 12 deletions

View file

@ -16,6 +16,30 @@ def _require_auth(request):
return None 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']) @require_http_methods(['GET'])
def book_list(request): def book_list(request):
err = _require_auth(request) err = _require_auth(request)
@ -210,13 +234,11 @@ def save_progress(request, pk):
user=request.user, user=request.user,
book=book, book=book,
) )
# Always advance to the furthest-read position unless the client # Always advance to the furthest-read position (by anchor block index, so a
# explicitly forces a reset (e.g. "start over" button in the reader). # transiently wrong scroll_fraction can't freeze the position) unless the
if force: # client explicitly forces a reset (e.g. "start over" button in the reader).
progress.scroll_fraction = scroll_fraction if force or _progress_is_further(position_anchor, scroll_fraction,
progress.position_anchor = position_anchor progress.position_anchor, progress.scroll_fraction):
else:
if scroll_fraction >= progress.scroll_fraction:
progress.scroll_fraction = scroll_fraction progress.scroll_fraction = scroll_fraction
progress.position_anchor = position_anchor progress.position_anchor = position_anchor
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at']) progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])

View file

@ -2699,6 +2699,26 @@ function restoreFromAnchor(contentEl, anchor) {
return true; 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 // Book cache — IndexedDB, evict after 4 weeks of inactivity
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2815,7 +2835,7 @@ async function _loadCachedBookMeta() {
} catch { return []; } } catch { return []; }
} }
async function _updateCachedBookProgress(bookId, fraction, anchor) { async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) {
try { try {
const db = await _openBookCacheDb(); const db = await _openBookCacheDb();
await new Promise(resolve => { await new Promise(resolve => {
@ -2824,7 +2844,8 @@ async function _updateCachedBookProgress(bookId, fraction, anchor) {
const req = store.get(bookId); const req = store.get(bookId);
req.onsuccess = e => { req.onsuccess = e => {
const entry = e.target.result; 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.scroll_fraction = fraction;
entry.position_anchor = anchor; entry.position_anchor = anchor;
entry.last_read = new Date().toISOString(); entry.last_read = new Date().toISOString();
@ -2988,7 +3009,7 @@ async function loadBookList() {
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) {
const loc = _localById[b.id]; 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/`, { fetch(`/books/${b.id}/progress/`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
@ -3731,7 +3752,7 @@ async function saveReaderProgress(force = false) {
_currentPositionAnchor = anchor; _currentPositionAnchor = anchor;
} }
_updateCachedBookProgress(currentBookId, fraction, anchor); _updateCachedBookProgress(currentBookId, fraction, anchor, force);
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})}); const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})});
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};

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-v14'; const CACHE = 'diora-v15';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',