Books: Fortschritt-Konflikt via max() lösen — weiter = gewinnt (SW v14)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s

Der am weitesten gelesene Wert hat immer Vorrang:
- Server: scroll_fraction = max(neu, aktuell) — ein afk-Fenster bei 10%
  kann nicht mehr 80% überschreiben
- Client loadBookList: wenn lokaler Fortschritt > Server, wird gepusht
  und für die Anzeige verwendet (deckt Offline-Lesen ab)
- Pending-Flag-Logik entfernt — nicht mehr nötig dank max-Semantik
- saveReaderProgress(force=true): neuer force-Parameter schreibt
  Position unabhängig vom gespeicherten Maximum; Button ⚑ im
  Reader-Header für den Ausnahmefall "zurück-zum-Anfang"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-05-31 15:41:35 +02:00
parent 28756ffdc2
commit 29f6cc4413
4 changed files with 48 additions and 87 deletions

View file

@ -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): if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
position_anchor = raw_anchor position_anchor = raw_anchor
force = bool(body.get('force', False))
progress, _ = EBookProgress.objects.get_or_create( progress, _ = EBookProgress.objects.get_or_create(
user=request.user, user=request.user,
book=book, book=book,
) )
progress.scroll_fraction = scroll_fraction # Always advance to the furthest-read position unless the client
progress.position_anchor = position_anchor # 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']) progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at'])
return JsonResponse({'ok': True}) return JsonResponse({'ok': True})

View file

@ -2788,29 +2788,10 @@ async function _saveBookMeta(books) {
try { try {
const db = await _openBookCacheDb(); const db = await _openBookCacheDb();
const ids = new Set(books.map(b => String(b.id))); 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 => { await new Promise(resolve => {
const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_META_STORE); const store = tx.objectStore(_BOOK_META_STORE);
for (const b of books) { for (const b of books) store.put(b);
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);
}
store.openCursor().onsuccess = e => { store.openCursor().onsuccess = e => {
const cursor = e.target.result; const cursor = e.target.result;
if (!cursor) return; if (!cursor) return;
@ -2879,8 +2860,7 @@ async function _loadAnnotationsCache(bookId, type) {
} catch { return null; } } catch { return null; }
} }
const _PENDING_SYNC_KEY = 'diora_pending_sync'; const _PENDING_SYNC_KEY = 'diora_pending_sync';
const _PENDING_PROGRESS_KEY = 'diora_pending_progress';
function _markPendingSync(bookId, type) { function _markPendingSync(bookId, type) {
try { try {
@ -2917,36 +2897,6 @@ async function _syncPendingAnnotations() {
} catch {} } 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 // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, 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}); 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 // If local cache is further ahead than the server, push it and use it for display.
const _pendingIds = new Set(JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]').map(String)); // Server uses max-semantics so this is always safe.
if (_pendingIds.size > 0) { const _localMeta = await _loadCachedBookMeta();
const _localMeta = await _loadCachedBookMeta(); 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];
if (_pendingIds.has(String(b.id))) { if (loc && loc.scroll_fraction > b.scroll_fraction) {
const loc = _localById[b.id]; fetch(`/books/${b.id}/progress/`, {
if (loc) { method: 'POST',
b.scroll_fraction = loc.scroll_fraction; headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
b.position_anchor = loc.position_anchor; body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}),
b.last_read = loc.last_read; }).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) {} } catch (e) {}
} }
async function saveReaderProgress() { async function saveReaderProgress(force = false) {
if (!currentBookId) return; if (!currentBookId) return;
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl) return; if (!contentEl) return;
@ -3780,24 +3731,25 @@ async function saveReaderProgress() {
_currentPositionAnchor = anchor; _currentPositionAnchor = anchor;
} }
// Always update local cache so offline re-opens land at the right position
_updateCachedBookProgress(currentBookId, fraction, anchor); _updateCachedBookProgress(currentBookId, fraction, anchor);
// Cache for sendBeacon on unload const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})});
_lastProgressBeacon = { _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
url: `/books/${currentBookId}/progress/`,
body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}),
};
try { try {
await fetch(`/books/${currentBookId}/progress/`, { await fetch(`/books/${currentBookId}/progress/`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: _lastProgressBeacon.body, body,
}); });
} catch (e) { if (force) {
if (!navigator.onLine) _markPendingProgress(currentBookId); 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() { function closeReader() {
@ -5202,8 +5154,7 @@ function openRadioSidebar() {
window.addEventListener('online', async () => { window.addEventListener('online', async () => {
showOfflineBanner(false); showOfflineBanner(false);
await _syncPendingAnnotations(); await _syncPendingAnnotations();
await _syncPendingProgress(); loadBookList(); // loadBookList pushes any locally-advanced progress automatically
loadBookList();
}); });
// Show banner immediately if already offline at startup // Show banner immediately if already offline at startup

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

View file

@ -338,11 +338,12 @@
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;"> <input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span> <span id="reader-progress-suffix" class="muted"></span>
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button> <button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button> <button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button> <button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button> <button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button> <button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents"></button>
<button class="btn-icon" id="reader-reset-pos-btn" onclick="saveReaderProgress(true)" title="Diese Position als Lesefortschritt setzen (überschreibt gespeicherten Fortschritt)"></button>
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button> <button class="btn-icon" onclick="closeReader()" title="Close (Esc)"></button>
</div> </div>
</div> </div>