diora-web/static/js/app/46-readpos.js
mrwnslz 656520fe8e
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 8m23s
Test / e2e (push) Failing after 1m54s
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne
Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei
'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der
beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale
Funktionen und let/const bleiben global, die App-Schichten (native.js,
native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen
die Dateien; diora-android build_web nimmt static/js rekursiv.

Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im
Reader, Hell/Dunkel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:43 +02:00

198 lines
7.5 KiB
JavaScript

'use strict';
// ---------------------------------------------------------------------------
// Reading position: the place last read wins, on any device (docs/progress-sync.md).
//
// A position counts as read when the reader got there by reading (scrolling,
// turning pages, choosing a chapter); its time is that moment, not the time it
// is saved. So re-saving an unchanged place, on a timer or on closing, carries
// the old time and can never beat a newer place read on another device. A book
// just opened at the server's place has read nothing here yet: nothing is sent.
// Own writes wait in a small outbox (localStorage) until the server has them.
// ---------------------------------------------------------------------------
let _serverOffset = 0; // the server's clock minus this device's (ms)
let _readPos = null; // the open book: {bookId, ready, key, readAt (device ms), moved, hold}
let _readerInteractedAt = 0;
function _progressDevice() {
return window.DIORA_DEVICE_NAME || 'Browser';
}
function _noteServerNow(ms) {
if (typeof ms === 'number' && ms > 1e12) _serverOffset = ms - Date.now();
}
function _noteServerNowHeader(res) {
try { _noteServerNow(Number(res.headers.get('X-Server-Now'))); } catch (e) {}
}
// The reader did something (scroll wheel, touch, key, mouse): the next change
// of position is reading, not a layout settling.
function _noteReaderInteraction() {
_readerInteractedAt = Date.now();
if (_readPos && _readPos.hold === 'until-moved') _readPos.hold = false;
}
window.__dioraReaderInteraction = _noteReaderInteraction;
// While a jump is only a look (a footnote's place, "Zurück zum Text"), the
// place before it stays the reading position. true: until the next
// interaction; false: released now.
window.__dioraHoldProgress = function (on) {
if (_readPos) _readPos.hold = on ? 'until-moved' : false;
};
function _positionKey(anchor, fraction) {
return anchor || 'f' + (fraction || 0).toFixed(4);
}
// Called with every computed position: a change that follows an interaction is reading.
function _noteReadingPosition(anchor, fraction) {
if (!_readPos || !_readPos.ready || _readPos.hold) return;
const key = _positionKey(anchor, fraction);
if (key === _readPos.key) return;
_readPos.key = key;
if (Date.now() - _readerInteractedAt < 10000) {
_readPos.readAt = Date.now();
_readPos.moved = true;
}
}
function _progressOutboxKey() {
return `diora_progress_outbox_${window.USER_ID || 0}`;
}
function _progressOutbox() {
try { return JSON.parse(localStorage.getItem(_progressOutboxKey()) || '{}') || {}; } catch (e) { return {}; }
}
function _progressOutboxPut(entry) {
try {
const box = _progressOutbox();
const old = box[entry.book];
if (old && !entry.force && old.read_at > entry.read_at) return;
box[entry.book] = entry;
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
} catch (e) {}
}
function _progressOutboxDone(bookId, readAt) {
try {
const box = _progressOutbox();
if (box[bookId] && box[bookId].read_at === readAt) {
delete box[bookId];
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
}
} catch (e) {}
}
function _progressBody(e) {
return JSON.stringify({
scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: e.read_at,
sent_at: Date.now(), device: e.device, ...(e.force && {force: true}),
});
}
// Send one queued position; true once the server has it (stored or not: it decides).
async function _sendProgress(e) {
try {
const res = await fetch(`/books/${e.book}/progress/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: _progressBody(e),
});
if (!(res.ok || (res.status >= 400 && res.status < 500))) return false;
try { _noteServerNow((await res.json()).server_now); } catch (err) {}
_progressOutboxDone(e.book, e.read_at);
return true;
} catch (err) {
return false;
}
}
let _progressFlushing = null;
function _flushProgressOutbox() {
if (!_progressFlushing) {
_progressFlushing = (async () => {
for (const e of Object.values(_progressOutbox())) await _sendProgress(e);
})().finally(() => { _progressFlushing = null; });
}
return _progressFlushing;
}
// A book's state from the server list, made newer by an own write the server doesn't have yet.
function _withPendingProgress(b) {
const e = _progressOutbox()[b.id];
if (!e) return b;
const at = e.read_at + _serverOffset;
if (b.read_at != null && at < b.read_at && !e.force) return b;
return {...b, scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: at,
device: e.device, last_read: new Date(at).toISOString()};
}
function _bookRecency(b) {
if (typeof b.read_at === 'number') return b.read_at;
const t = b.last_read ? Date.parse(b.last_read) : NaN;
return isNaN(t) ? 0 : t;
}
function _readerToast(text, button, onClick, ms) {
const toast = document.createElement('div');
toast.className = 'reader-toast reader-toast-action';
toast.innerHTML = '<span></span><button type="button" class="reader-toast-btn"></button>';
toast.querySelector('span').textContent = text;
toast.querySelector('button').textContent = button;
document.body.appendChild(toast);
const done = () => toast.remove();
toast.querySelector('button').addEventListener('click', () => { done(); onClick(); });
setTimeout(done, ms || 10000);
return toast;
}
function _jumpToPosition(anchor, fraction) {
const contentEl = $('reader-content');
if (!contentEl) return;
_suppressScrollJumpDetect();
if (currentPdfDoc && readerSettings.pdfPaginated && pdfTotalPages > 1) {
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
enterPdfPaginatedMode();
} else if (currentPdfDoc) {
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
} else if (!restoreFromAnchor(contentEl, anchor)) {
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
}
}
// Another device read this book after the place shown here: offer to go there.
let _newerOfferShown = null;
async function _checkNewerPositionElsewhere() {
const pos = _readPos;
if (!pos || !pos.ready || !currentBookId || pos.bookId !== currentBookId) return;
let b;
try {
const res = await fetch('/books/', {cache: 'no-store'});
_noteServerNowHeader(res);
b = (await res.json()).find(x => x.id === pos.bookId);
} catch (e) { return; }
if (!b || b.read_at == null || _readPos !== pos) return;
if (b.read_at <= pos.readAt + _serverOffset + 3000) return;
if (_positionKey(b.position_anchor || '', b.scroll_fraction) === pos.key) return;
const offerKey = `${pos.bookId}:${b.read_at}`;
if (_newerOfferShown === offerKey) return;
_newerOfferShown = offerKey;
const where = b.device ? `Auf ${b.device}` : 'Auf einem anderen Gerät';
_readerToast(`${where} weitergelesen · ${_formatBookPercent(b.scroll_fraction || 0)} %`, 'Dorthin', () => {
if (_readPos !== pos) return;
_jumpToPosition(b.position_anchor || '', b.scroll_fraction || 0);
// That place and its time are the server's: nothing new to tell it.
pos.key = null;
pos.readAt = b.read_at - _serverOffset;
pos.moved = false;
setTimeout(() => {
const contentEl = $('reader-content');
if (_readPos === pos && contentEl) {
pos.key = currentPdfDoc ? _positionKey('', b.scroll_fraction || 0) : _positionKey(getPositionAnchor(contentEl), 0);
}
}, 300);
}, 15000);
}
window.__dioraCheckNewerPosition = _checkNewerPositionElsewhere;