diora-web/static/js/app/48-book-cache.js

297 lines
12 KiB
JavaScript
Raw Permalink Normal View History

'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:<path>" 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 {}
}