Books: Fortschritt-Konflikt via Anker-Blockindex statt scroll_fraction (SW v15)
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:
parent
29f6cc4413
commit
daffd0002b
3 changed files with 55 additions and 12 deletions
|
|
@ -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,15 +234,13 @@ 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).
|
||||||
|
if force or _progress_is_further(position_anchor, scroll_fraction,
|
||||||
|
progress.position_anchor, progress.scroll_fraction):
|
||||||
progress.scroll_fraction = scroll_fraction
|
progress.scroll_fraction = scroll_fraction
|
||||||
progress.position_anchor = position_anchor
|
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'])
|
progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
|
||||||
|
|
||||||
return JsonResponse({'ok': True})
|
return JsonResponse({'ok': True})
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue