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
|
||||
|
||||
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue