Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
'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;
|
Lesen in Seiten auch im Browser und auf dem Desktop, mit Fußnoten unten auf der Seite und Doppelseite
63-pages.js (DioraPages): die Seitenansicht des E-Reader-Modus der Android-Apps, jetzt für alle –
Seitenumbrüche vor angeschnittenen Zeilen, ein Kapitel auf einmal, Fußnoten am Seitenfuß wie im
gedruckten Buch, Blättern per Tippen/Klick am Rand, Wischen, Pfeil-/Bild-/Leertaste und Mausrad.
Neu: Doppelseite (zwei Seiten nebeneinander ab 900 px Breite). Einstellung „Scrollen | Seiten“
(+ „Fußnoten unten“, „Doppelseite“) im Schrift-&-Layout-Panel, je Gerät.
Fußnoten: Erkennung und Auflösung aus diora-android native-ui.js übernommen (40-epub.js,
78-footnotes.js) – die Notiz wird im richtigen Kapitel gesucht (IDs wiederholen sich je Datei),
auch das Popover zeigt jetzt genau die Notiz. Vorbereitete Bücher werden einmal neu aufbereitet.
Behoben gegenüber der App-Fassung: ein Verweis in der ersten Zeile einer Seite (hochgestellt)
zählte zur Vorseite, und ein Verweis, der samt Notiz nicht mehr passte, blieb ohne Notiz stehen –
jetzt wandert er mit ihr auf die nächste Seite. Schnelles Blättern löst „Weit gescrollt“ nicht mehr aus.
sw CACHE v157
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:41:27 +01:00
|
|
|
// window.__dioraPrepareSig: what an app layer adds to the preparation — a
|
|
|
|
|
// change there too.
|
|
|
|
|
const src = [parseEpub, sanitizeEpubHtml, _looksLikeFootnoteLink, _isNoteBacklink, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
|
2026-10-14 19:54:12 +02:00
|
|
|
.map(f => String(f)).join('\n') + (window.__dioraPrepareSig || '');
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
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 {}
|
|
|
|
|
}
|