diff --git a/static/js/app.js b/static/js/app.js index b04629b..d43a22a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2617,6 +2617,65 @@ function restoreFromAnchor(contentEl, anchor) { return true; } +// --------------------------------------------------------------------------- +// Book cache — IndexedDB, evict after 4 weeks of inactivity +// --------------------------------------------------------------------------- +const _BOOK_CACHE_STORE = 'books'; +const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000; + +function _openBookCacheDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('diora_book_cache', 1); + req.onupgradeneeded = e => e.target.result.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'}); + req.onsuccess = e => resolve(e.target.result); + req.onerror = () => reject(); + }); +} + +async function _getCachedBook(bookId) { + try { + const db = await _openBookCacheDb(); + return await new Promise((resolve) => { + const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId); + req.onsuccess = e => resolve(e.target.result || null); + req.onerror = () => resolve(null); + }); + } catch { return null; } +} + +async function _setCachedBook(bookId, data_ct, data_iv) { + try { + const db = await _openBookCacheDb(); + await new Promise((resolve) => { + const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); + tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()}); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch {} +} + +async function _evictBookCache(bookList) { + try { + const db = await _openBookCacheDb(); + const now = Date.now(); + const metaById = Object.fromEntries(bookList.map(b => [b.id, b])); + await new Promise((resolve) => { + const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); + const store = tx.objectStore(_BOOK_CACHE_STORE); + store.openCursor().onsuccess = e => { + const cursor = e.target.result; + if (!cursor) { resolve(); return; } + const meta = metaById[cursor.value.id]; + const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0; + if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete(); + cursor.continue(); + }; + tx.onerror = resolve; + }); + } catch {} +} + // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', pdfZoom: 100, pdfInverted: false, pdfPaginated: false }; @@ -2668,6 +2727,7 @@ async function loadBookList() { return; } const books = await res.json(); + _evictBookCache(books); // fire-and-forget if (!Array.isArray(books)) { listEl.innerHTML = `
Unexpected response from server (not an array).
`; return; @@ -2996,8 +3056,15 @@ async function openBook(bookId) { try { loadReaderSettings(); const key = await getOrCreateEncKey(); - const res = await fetch(`/books/${bookId}/data/`); - const {data_ct, data_iv} = await res.json(); + let data_ct, data_iv; + const cached = await _getCachedBook(bookId); + if (cached) { + ({data_ct, data_iv} = cached); + } else { + const res = await fetch(`/books/${bookId}/data/`); + ({data_ct, data_iv} = await res.json()); + _setCachedBook(bookId, data_ct, data_iv); // fire-and-forget + } const plain = await decryptBytes(key, data_iv, data_ct); // Revoke any previous image blob URLs @@ -3098,8 +3165,10 @@ async function openBook(bookId) { const fraction = bookData ? (bookData.scroll_fraction || 0) : 0; const anchor = bookData ? (bookData.position_anchor || '') : ''; _currentPositionAnchor = anchor; - if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { - if (fraction > 0) pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + if (isPdf && readerSettings.pdfPaginated) { + if (fraction > 0 && pdfTotalPages > 1) + pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + enterPdfPaginatedMode(); } else if (!isPdf) { // Wait for all images so scrollHeight is final const imgs = Array.from(contentEl.querySelectorAll('img'));