Reader: Buch nie doppelt öffnen; ungültige Anker selbst reparieren (SW v48)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 1m34s

openBook leert #reader-content und hängt die Kapitel über mehrere awaits
an. Ein zweiter Aufruf dazwischen (zweites Tippen, während ein langsamer
E-Reader noch entschlüsselt) hing das Buch doppelt an. Daraus gespeicherte
Anker lagen hinter dem echten Buchende (tolino: Block 1491 bei 1206
Blöcken) und schlugen beim Server-Vergleich "weitester Stand gewinnt"
jede echte Position: Fortschritt wurde nicht mehr gespeichert.

- openBook läuft nur einmal gleichzeitig (gleiches Buch: auf den laufenden
  Aufruf warten; anderes Buch: danach öffnen).
- Aus einem doppelt gerenderten Dokument wird kein Fortschritt gespeichert.
- Ein gespeicherter Anker, der nicht zum Buch passt, führt zum Textanteil
  (scroll_fraction) statt an den Anfang, und die nächste Speicherung
  überschreibt ihn (force), damit der Fortschritt wieder läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-09-28 21:19:05 +02:00
parent 7ee44622ee
commit 086ce29020
2 changed files with 43 additions and 4 deletions

View file

@ -4505,7 +4505,30 @@ function exitReaderImmersiveMode() {
document.removeEventListener('click', _immHandleTap); document.removeEventListener('click', _immHandleTap);
} }
// Serializes openBook: it empties #reader-content, then appends the chapters
// across several awaits. A second call in between (a second tap while a slow
// e-reader is still decrypting) appended the whole book a second time — and
// progress saved from that doubled DOM had block indices beyond the real book,
// which then out-ranked every real position on the server.
let _openBookInFlight = null;
let _repairProgressAnchor = false; // stored anchor didn't fit this book
async function openBook(bookId) { async function openBook(bookId) {
if (_openBookInFlight) {
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
await _openBookInFlight.promise.catch(() => {});
}
const promise = _openBookOnce(bookId);
_openBookInFlight = {bookId, promise};
try {
return await promise;
} finally {
if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null;
}
}
async function _openBookOnce(bookId) {
_repairProgressAnchor = false;
const overlay = $('reader-overlay'); const overlay = $('reader-overlay');
const contentEl = $('reader-content'); const contentEl = $('reader-content');
const titleEl = $('reader-title'); const titleEl = $('reader-title');
@ -4690,7 +4713,14 @@ async function openBook(bookId) {
} }
await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r));
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
}
// An anchor past the end of this book can only come from a broken
// render. The server keeps the furthest block index, so it would block
// every real position from now on: overwrite it with the next save.
const [storedBlock] = _anchorParts(anchor);
if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) {
_repairProgressAnchor = true;
} }
} }
} catch (e) {} } catch (e) {}
@ -4844,6 +4874,13 @@ async function saveReaderProgress(force = false) {
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
} }
// Chapter wrappers carry unique ids; seeing one twice means the book was
// rendered twice. Positions from that DOM are wrong — don't store them.
if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) {
console.warn('Book rendered twice, progress not saved');
return;
}
let anchor = ''; let anchor = '';
if (!currentPdfDoc) { if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl); anchor = getPositionAnchor(contentEl);
@ -4854,9 +4891,11 @@ async function saveReaderProgress(force = false) {
} }
fraction = Math.min(1.0, Math.max(0.0, fraction)); fraction = Math.min(1.0, Math.max(0.0, fraction));
_updateCachedBookProgress(currentBookId, fraction, anchor, force); _updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor));
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})}); const repair = _repairProgressAnchor && !!anchor;
if (repair) _repairProgressAnchor = false;
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})});
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
try { try {

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