'use strict'; // --------------------------------------------------------------------------- // Book cache — IndexedDB, evict after 4 weeks of inactivity // --------------------------------------------------------------------------- const _BOOK_CACHE_STORE = 'books'; const _BOOK_META_STORE = 'book_meta'; const _BOOK_ANNOTATIONS_STORE = 'book_annotations'; const _BOOK_PARSED_STORE = 'parsed'; // prepared chapters, see _getParsedBook 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', 3); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) db.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_META_STORE)) db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE)) db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_PARSED_STORE)) db.createObjectStore(_BOOK_PARSED_STORE, {keyPath: 'id'}); }; req.onsuccess = e => resolve(e.target.result); req.onerror = () => reject(); }); } // --------------------------------------------------------------------------- // Prepared books: an EPUB's chapters after unpacking and sanitizing, so opening // it again skips both (for a book like Das Kapital that's most of the time an // e-reader needs to open it). Encrypted with the book key like everything else // on the device. Valid for one version of the book file (its IV) and of the // code that prepares chapters (a hash of its source: changing the sanitizer // makes every book be prepared anew). Images stay in the book file: the // chapters keep "diora-img:" in place of the session's blob URLs. // --------------------------------------------------------------------------- let _parseCodeSig = null; function _parsePreparationSig() { if (_parseCodeSig) return _parseCodeSig; // window.__dioraPrepareSig: what an app layer adds to the preparation — a // change there too. const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] .map(f => String(f)).join('\n') + (window.__dioraPrepareSig || ''); let h = 5381; for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0; return (_parseCodeSig = (h >>> 0).toString(36) + '.' + src.length); } async function _getParsedBook(bookId, dataIv, key) { try { const db = await _openBookCacheDb(); const row = await new Promise(resolve => { const req = db.transaction(_BOOK_PARSED_STORE).objectStore(_BOOK_PARSED_STORE).get(bookId); req.onsuccess = e => resolve(e.target.result || null); req.onerror = () => resolve(null); }); if (!row || row.sig !== dataIv + ':' + _parsePreparationSig()) return null; const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: row.iv}, key, row.ct); return JSON.parse(new TextDecoder().decode(plain)); } catch (e) { return null; } } async function _setParsedBook(bookId, dataIv, key, result) { try { // Blob URLs live as long as this page; the stored chapters name the image instead. const pathOf = {}; for (const [path, url] of Object.entries(result.imageMap || {})) pathOf[url] = path; const blobRe = /blob:[^"'\s)]+/g; const chapters = result.chapters.map(c => c.replace(blobRe, u => pathOf[u] ? 'diora-img:' + pathOf[u] : u)); const doc = {title: result.title, author: result.author, toc: result.toc, chapters, images: Object.keys(result.imageMap || {})}; // Raw bytes, not base64: IndexedDB keeps an ArrayBuffer as it is (megabytes for a big book). const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(JSON.stringify(doc))); const db = await _openBookCacheDb(); await new Promise(resolve => { const tx = db.transaction(_BOOK_PARSED_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_PARSED_STORE); store.put({id: bookId, sig: dataIv + ':' + _parsePreparationSig(), iv, ct, cached_at: Date.now()}); // Prepared books not opened for a while go (they come back on the next open). const all = store.getAll(); all.onsuccess = () => all.result.forEach(r => { if (r.id !== bookId && Date.now() - (r.cached_at || 0) > _BOOK_CACHE_EVICT_MS) store.delete(r.id); }); tx.oncomplete = resolve; tx.onerror = resolve; }); } catch (e) {} } /** A prepared book back to what parseEpub returns: fresh blob URLs for its images. */ async function _restoreParsedBook(doc, epubBytes) { const imageMap = {}; if (doc.images && doc.images.length) { const zip = await JSZip.loadAsync(epubBytes); await Promise.all(doc.images.map(async path => { const f = zip.file(path); if (!f) return; try { const buf = await f.async('arraybuffer'); const ext = path.split('.').pop().toLowerCase(); const type = {jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp'}[ext] || ''; imageMap[path] = URL.createObjectURL(new Blob([buf], type ? {type} : {})); } catch (e) {} })); } const placeholder = /diora-img:([^"'\s)]+)/g; const chapters = doc.chapters.map(c => c.replace(placeholder, (m, path) => imageMap[path] || '')); return {title: doc.title, author: doc.author, toc: doc.toc, chapters, imageMap}; } 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; }); // Request persistent storage so the browser doesn't evict cached books // under storage pressure. Chrome grants this silently for installed PWAs; // Firefox may show a permission prompt. if (navigator.storage?.persist) navigator.storage.persist(); } catch {} } async function _evictCachedBook(bookId) { try { const db = await _openBookCacheDb(); await new Promise((resolve) => { const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); tx.objectStore(_BOOK_CACHE_STORE).delete(bookId); 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 {} } async function _saveBookMeta(books) { try { const db = await _openBookCacheDb(); const ids = new Set(books.map(b => String(b.id))); await new Promise(resolve => { const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_META_STORE); for (const b of books) store.put(b); store.openCursor().onsuccess = e => { const cursor = e.target.result; if (!cursor) return; if (!ids.has(String(cursor.value.id))) cursor.delete(); cursor.continue(); }; tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _loadCachedBookMeta() { try { const db = await _openBookCacheDb(); return await new Promise(resolve => { const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll(); req.onsuccess = e => resolve(e.target.result || []); req.onerror = () => resolve([]); }); } catch { return []; } } async function _updateCachedBookProgress(bookId, fraction, anchor, force = false, readAt = null) { const at = (readAt == null ? Date.now() : readAt) + _serverOffset; try { const db = await _openBookCacheDb(); await new Promise(resolve => { const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); const store = tx.objectStore(_BOOK_META_STORE); const req = store.get(bookId); req.onsuccess = e => { const entry = e.target.result; // The place read last, as on the server (docs/progress-sync.md). if (entry && (force || entry.read_at == null || at >= entry.read_at)) { entry.scroll_fraction = fraction; entry.position_anchor = anchor; entry.read_at = at; entry.device = _progressDevice(); entry.last_read = new Date(at).toISOString(); if (_cmpProgress(anchor, fraction, entry.furthest_anchor || '', entry.furthest_fraction || 0) > 0) { entry.furthest_anchor = anchor; entry.furthest_fraction = fraction; } store.put(entry); } }; tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _saveAnnotationsCache(bookId, type, ct, iv) { try { const db = await _openBookCacheDb(); await new Promise(resolve => { const tx = db.transaction(_BOOK_ANNOTATIONS_STORE, 'readwrite'); tx.objectStore(_BOOK_ANNOTATIONS_STORE).put({id: `${bookId}_${type}`, ct, iv}); tx.oncomplete = resolve; tx.onerror = resolve; }); } catch {} } async function _loadAnnotationsCache(bookId, type) { try { const db = await _openBookCacheDb(); return await new Promise(resolve => { const req = db.transaction(_BOOK_ANNOTATIONS_STORE).objectStore(_BOOK_ANNOTATIONS_STORE).get(`${bookId}_${type}`); req.onsuccess = e => resolve(e.target.result || null); req.onerror = () => resolve(null); }); } catch { return null; } } const _PENDING_SYNC_KEY = 'diora_pending_sync'; function _markPendingSync(bookId, type) { try { const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); const key = `${bookId}:${type}`; if (!pending.includes(key)) pending.push(key); localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending)); } catch {} } async function _syncPendingAnnotations() { try { const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); if (!pending.length) return; const remaining = []; for (const key of pending) { const sep = key.indexOf(':'); const bookId = key.slice(0, sep); const type = key.slice(sep + 1); const cached = await _loadAnnotationsCache(bookId, type); if (!cached?.ct) continue; const url = type === 'bookmarks' ? `/books/${bookId}/bookmarks/` : `/books/${bookId}/highlights/`; try { await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, body: JSON.stringify({ct: cached.ct, iv: cached.iv}), }); } catch { remaining.push(key); } } localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining)); } catch {} }