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>
198 lines
7.5 KiB
JavaScript
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;
|