Books: Fortschritt-Konflikt via max() lösen — weiter = gewinnt (SW v14)
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:
parent
28756ffdc2
commit
29f6cc4413
4 changed files with 48 additions and 87 deletions
|
|
@ -204,10 +204,19 @@ def save_progress(request, pk):
|
|||
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
|
||||
position_anchor = raw_anchor
|
||||
|
||||
force = bool(body.get('force', False))
|
||||
|
||||
progress, _ = EBookProgress.objects.get_or_create(
|
||||
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:
|
||||
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'])
|
||||
|
|
|
|||
|
|
@ -2788,29 +2788,10 @@ async function _saveBookMeta(books) {
|
|||
try {
|
||||
const db = await _openBookCacheDb();
|
||||
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 => {
|
||||
const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
|
||||
const store = tx.objectStore(_BOOK_META_STORE);
|
||||
for (const b of books) {
|
||||
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);
|
||||
}
|
||||
for (const b of books) store.put(b);
|
||||
store.openCursor().onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (!cursor) return;
|
||||
|
|
@ -2880,7 +2861,6 @@ async function _loadAnnotationsCache(bookId, type) {
|
|||
}
|
||||
|
||||
const _PENDING_SYNC_KEY = 'diora_pending_sync';
|
||||
const _PENDING_PROGRESS_KEY = 'diora_pending_progress';
|
||||
|
||||
function _markPendingSync(bookId, type) {
|
||||
try {
|
||||
|
|
@ -2917,36 +2897,6 @@ async function _syncPendingAnnotations() {
|
|||
} 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
|
||||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||||
fontFamily: 'serif', noBold: false,
|
||||
|
|
@ -3032,22 +2982,23 @@ 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});
|
||||
}
|
||||
}
|
||||
// Use local progress for books that have unsaved offline changes
|
||||
const _pendingIds = new Set(JSON.parse(localStorage.getItem(_PENDING_PROGRESS_KEY) || '[]').map(String));
|
||||
if (_pendingIds.size > 0) {
|
||||
// If local cache is further ahead than the server, push it and use it for display.
|
||||
// Server uses max-semantics so this is always safe.
|
||||
const _localMeta = await _loadCachedBookMeta();
|
||||
const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b]));
|
||||
for (const b of decrypted) {
|
||||
if (_pendingIds.has(String(b.id))) {
|
||||
const loc = _localById[b.id];
|
||||
if (loc) {
|
||||
if (loc && loc.scroll_fraction > b.scroll_fraction) {
|
||||
fetch(`/books/${b.id}/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(() => {});
|
||||
b.scroll_fraction = loc.scroll_fraction;
|
||||
b.position_anchor = loc.position_anchor;
|
||||
b.last_read = loc.last_read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decrypted.sort((a, b) => {
|
||||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||||
|
|
@ -3761,7 +3712,7 @@ async function deleteBook(bookId) {
|
|||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function saveReaderProgress() {
|
||||
async function saveReaderProgress(force = false) {
|
||||
if (!currentBookId) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
|
@ -3780,24 +3731,25 @@ async function saveReaderProgress() {
|
|||
_currentPositionAnchor = anchor;
|
||||
}
|
||||
|
||||
// Always update local cache so offline re-opens land at the right position
|
||||
_updateCachedBookProgress(currentBookId, fraction, anchor);
|
||||
|
||||
// Cache for sendBeacon on unload
|
||||
_lastProgressBeacon = {
|
||||
url: `/books/${currentBookId}/progress/`,
|
||||
body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}),
|
||||
};
|
||||
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})});
|
||||
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
|
||||
|
||||
try {
|
||||
await fetch(`/books/${currentBookId}/progress/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body: _lastProgressBeacon.body,
|
||||
body,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!navigator.onLine) _markPendingProgress(currentBookId);
|
||||
if (force) {
|
||||
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() {
|
||||
|
|
@ -5202,8 +5154,7 @@ function openRadioSidebar() {
|
|||
window.addEventListener('online', async () => {
|
||||
showOfflineBanner(false);
|
||||
await _syncPendingAnnotations();
|
||||
await _syncPendingProgress();
|
||||
loadBookList();
|
||||
loadBookList(); // loadBookList pushes any locally-advanced progress automatically
|
||||
});
|
||||
|
||||
// Show banner immediately if already offline at startup
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 SHELL = [
|
||||
'/static/css/app.css',
|
||||
|
|
|
|||
|
|
@ -343,6 +343,7 @@
|
|||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue