'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 = ''; 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;