Books: vollständiger Offline-Modus für EPUB-Reader
- IndexedDB v2 mit zwei neuen Stores: book_meta (Buchliste + Lesefortschritt) und book_annotations (Lesezeichen & Highlights) - loadBookList fällt bei Netzwerkfehler auf lokalen Meta-Cache zurück, blendet Upload-Zone aus und zeigt Offline-Hinweis - Leseposition (scroll_fraction + position_anchor) wird bei jedem Auto-Save lokal gecacht, sodass offline-Öffnen am richtigen Ort landet - Lesezeichen und Highlights werden nach Server-Abruf lokal gecacht und bei Netzwerkfehler von dort geladen; Änderungen offline bleiben bis zur nächsten Synchronisierung erhalten - Mobile Reader-Header: kompaktere Abstände, Fortschritts-Input auf kleinen Screens ausgeblendet Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
159aa6f340
commit
bfa0439aa1
3 changed files with 173 additions and 14 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -33,3 +33,6 @@ CLAUDE.md
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
antennapod-feeds-2026-03-19.opml
|
||||||
|
.gitignore
|
||||||
|
playlist.m3u
|
||||||
|
|
|
||||||
|
|
@ -1439,6 +1439,13 @@ body.dnd-mode .timer-display {
|
||||||
.podcast-thumb-lg { width: 60px; height: 60px; }
|
.podcast-thumb-lg { width: 60px; height: 60px; }
|
||||||
.podcast-feed-actions { flex-direction: column; }
|
.podcast-feed-actions { flex-direction: column; }
|
||||||
.episode-actions { flex-direction: column; }
|
.episode-actions { flex-direction: column; }
|
||||||
|
|
||||||
|
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
|
||||||
|
.reader-header { padding: 6px 8px; gap: 4px; }
|
||||||
|
.reader-title { font-size: 13px; }
|
||||||
|
.reader-header-actions { gap: 4px; }
|
||||||
|
.reader-progress-wrap { display: none; }
|
||||||
|
.reader-content { padding: 16px 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
|
|
@ -1473,6 +1480,12 @@ body.dnd-mode .timer-display {
|
||||||
padding: 24px 0 16px;
|
padding: 24px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-notice {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.book-key-bar {
|
.book-key-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
171
static/js/app.js
171
static/js/app.js
|
|
@ -2689,13 +2689,23 @@ function restoreFromAnchor(contentEl, anchor) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Book cache — IndexedDB, evict after 4 weeks of inactivity
|
// Book cache — IndexedDB, evict after 4 weeks of inactivity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const _BOOK_CACHE_STORE = 'books';
|
const _BOOK_CACHE_STORE = 'books';
|
||||||
const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
|
const _BOOK_META_STORE = 'book_meta';
|
||||||
|
const _BOOK_ANNOTATIONS_STORE = 'book_annotations';
|
||||||
|
const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function _openBookCacheDb() {
|
function _openBookCacheDb() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = indexedDB.open('diora_book_cache', 1);
|
const req = indexedDB.open('diora_book_cache', 2);
|
||||||
req.onupgradeneeded = e => e.target.result.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'});
|
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'});
|
||||||
|
};
|
||||||
req.onsuccess = e => resolve(e.target.result);
|
req.onsuccess = e => resolve(e.target.result);
|
||||||
req.onerror = () => reject();
|
req.onerror = () => reject();
|
||||||
});
|
});
|
||||||
|
|
@ -2757,6 +2767,82 @@ async function _evictBookCache(bookList) {
|
||||||
} catch {}
|
} 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) {
|
||||||
|
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;
|
||||||
|
if (entry) {
|
||||||
|
entry.scroll_fraction = fraction;
|
||||||
|
entry.position_anchor = anchor;
|
||||||
|
entry.last_read = new Date().toISOString();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
// Reader settings
|
// Reader settings
|
||||||
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
|
||||||
fontFamily: 'serif', noBold: false,
|
fontFamily: 'serif', noBold: false,
|
||||||
|
|
@ -2836,10 +2922,10 @@ async function loadBookList() {
|
||||||
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
|
||||||
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
|
||||||
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'};
|
||||||
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true});
|
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'};
|
||||||
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false});
|
decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
decrypted.sort((a, b) => {
|
decrypted.sort((a, b) => {
|
||||||
|
|
@ -2848,9 +2934,29 @@ async function loadBookList() {
|
||||||
if (b.last_read) return 1;
|
if (b.last_read) return 1;
|
||||||
return b.uploaded_at.localeCompare(a.uploaded_at);
|
return b.uploaded_at.localeCompare(a.uploaded_at);
|
||||||
});
|
});
|
||||||
|
_saveBookMeta(decrypted); // persist for offline
|
||||||
renderBookList(decrypted);
|
renderBookList(decrypted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (listEl) listEl.innerHTML = `<p class="muted">Error loading books: ${e.message}</p>`;
|
// Network error — try offline fallback
|
||||||
|
const cachedBooks = await _loadCachedBookMeta();
|
||||||
|
if (cachedBooks.length) {
|
||||||
|
cachedBooks.sort((a, b) => {
|
||||||
|
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||||||
|
if (a.last_read) return -1;
|
||||||
|
if (b.last_read) return 1;
|
||||||
|
return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
|
||||||
|
});
|
||||||
|
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub'};
|
||||||
|
const uploadArea = $('book-upload-area');
|
||||||
|
if (uploadArea) uploadArea.style.display = 'none';
|
||||||
|
renderBookList(cachedBooks);
|
||||||
|
const notice = document.createElement('p');
|
||||||
|
notice.className = 'muted offline-notice';
|
||||||
|
notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.';
|
||||||
|
listEl.prepend(notice);
|
||||||
|
} else {
|
||||||
|
if (listEl) listEl.innerHTML = `<p class="muted">Error loading books: ${e.message}</p>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3381,11 +3487,20 @@ async function openBook(bookId) {
|
||||||
|
|
||||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||||
try {
|
try {
|
||||||
const progressRes = await fetch('/books/');
|
let fraction = 0, anchor = '';
|
||||||
const allBooks = await progressRes.json();
|
try {
|
||||||
const bookData = allBooks.find(b => b.id === bookId);
|
const progressRes = await fetch('/books/');
|
||||||
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
const allBooks = await progressRes.json();
|
||||||
const anchor = bookData ? (bookData.position_anchor || '') : '';
|
const bookData = allBooks.find(b => b.id === bookId);
|
||||||
|
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||||
|
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||||
|
} catch {
|
||||||
|
// offline — use cached meta
|
||||||
|
const cached = await _loadCachedBookMeta();
|
||||||
|
const bookData = cached.find(b => b.id === bookId);
|
||||||
|
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||||
|
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||||
|
}
|
||||||
_currentPositionAnchor = anchor;
|
_currentPositionAnchor = anchor;
|
||||||
if (isPdf && readerSettings.pdfPaginated) {
|
if (isPdf && readerSettings.pdfPaginated) {
|
||||||
if (fraction > 0 && pdfTotalPages > 1)
|
if (fraction > 0 && pdfTotalPages > 1)
|
||||||
|
|
@ -3532,6 +3647,9 @@ async function saveReaderProgress() {
|
||||||
_currentPositionAnchor = anchor;
|
_currentPositionAnchor = anchor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always update local cache so offline re-opens land at the right position
|
||||||
|
_updateCachedBookProgress(currentBookId, fraction, anchor);
|
||||||
|
|
||||||
// Cache for sendBeacon on unload
|
// Cache for sendBeacon on unload
|
||||||
_lastProgressBeacon = {
|
_lastProgressBeacon = {
|
||||||
url: `/books/${currentBookId}/progress/`,
|
url: `/books/${currentBookId}/progress/`,
|
||||||
|
|
@ -4117,6 +4235,7 @@ async function loadBookmarks(bookId) {
|
||||||
const res = await fetch(`/books/${bookId}/bookmarks/`);
|
const res = await fetch(`/books/${bookId}/bookmarks/`);
|
||||||
const {ct, iv} = await res.json();
|
const {ct, iv} = await res.json();
|
||||||
if (ct) {
|
if (ct) {
|
||||||
|
_saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
const plain = await decryptBytes(key, iv, ct);
|
const plain = await decryptBytes(key, iv, ct);
|
||||||
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
||||||
|
|
@ -4124,7 +4243,17 @@ async function loadBookmarks(bookId) {
|
||||||
currentBookmarks = [];
|
currentBookmarks = [];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
currentBookmarks = [];
|
// offline fallback
|
||||||
|
const cached = await _loadAnnotationsCache(bookId, 'bookmarks');
|
||||||
|
if (cached?.ct) {
|
||||||
|
try {
|
||||||
|
const key = await getOrCreateEncKey();
|
||||||
|
const plain = await decryptBytes(key, cached.iv, cached.ct);
|
||||||
|
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
|
||||||
|
} catch { currentBookmarks = []; }
|
||||||
|
} else {
|
||||||
|
currentBookmarks = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4137,6 +4266,7 @@ async function saveBookmarks() {
|
||||||
const body = JSON.stringify({ct: ciphertext, iv});
|
const body = JSON.stringify({ct: ciphertext, iv});
|
||||||
const url = `/books/${currentBookId}/bookmarks/`;
|
const url = `/books/${currentBookId}/bookmarks/`;
|
||||||
_lastBookmarkBeacon = {url, body};
|
_lastBookmarkBeacon = {url, body};
|
||||||
|
_saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||||
|
|
@ -4435,6 +4565,7 @@ async function loadHighlights(bookId) {
|
||||||
const res = await fetch(`/books/${bookId}/highlights/`);
|
const res = await fetch(`/books/${bookId}/highlights/`);
|
||||||
const {ct, iv} = await res.json();
|
const {ct, iv} = await res.json();
|
||||||
if (ct) {
|
if (ct) {
|
||||||
|
_saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
const plain = await decryptBytes(key, iv, ct);
|
const plain = await decryptBytes(key, iv, ct);
|
||||||
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
|
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
|
||||||
|
|
@ -4443,7 +4574,18 @@ async function loadHighlights(bookId) {
|
||||||
}
|
}
|
||||||
applyHighlightsToContent();
|
applyHighlightsToContent();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
currentHighlights = [];
|
// offline fallback
|
||||||
|
const cached = await _loadAnnotationsCache(bookId, 'highlights');
|
||||||
|
if (cached?.ct) {
|
||||||
|
try {
|
||||||
|
const key = await getOrCreateEncKey();
|
||||||
|
const plain = await decryptBytes(key, cached.iv, cached.ct);
|
||||||
|
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
|
||||||
|
} catch { currentHighlights = []; }
|
||||||
|
} else {
|
||||||
|
currentHighlights = [];
|
||||||
|
}
|
||||||
|
applyHighlightsToContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4456,6 +4598,7 @@ async function saveHighlights() {
|
||||||
const body = JSON.stringify({ct: ciphertext, iv});
|
const body = JSON.stringify({ct: ciphertext, iv});
|
||||||
const url = `/books/${currentBookId}/highlights/`;
|
const url = `/books/${currentBookId}/highlights/`;
|
||||||
_lastHighlightBeacon = {url, body};
|
_lastHighlightBeacon = {url, body};
|
||||||
|
_saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue