Books: vollständiger Offline-Modus für EPUB-Reader
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 17s

- 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:
marwin 2026-05-29 21:14:40 +02:00
parent 159aa6f340
commit bfa0439aa1
3 changed files with 173 additions and 14 deletions

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ CLAUDE.md
# OS
.DS_Store
Thumbs.db
antennapod-feeds-2026-03-19.opml
.gitignore
playlist.m3u

View file

@ -1439,6 +1439,13 @@ body.dnd-mode .timer-display {
.podcast-thumb-lg { width: 60px; height: 60px; }
.podcast-feed-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;
}
.offline-notice {
font-size: 13px;
color: var(--accent);
padding: 4px 0 8px;
}
.book-key-bar {
display: flex;
align-items: center;

View file

@ -2690,12 +2690,22 @@ function restoreFromAnchor(contentEl, anchor) {
// 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_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'});
const req = indexedDB.open('diora_book_cache', 2);
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.onerror = () => reject();
});
@ -2757,6 +2767,82 @@ async function _evictBookCache(bookList) {
} 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
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false,
@ -2836,10 +2922,10 @@ async function loadBookList() {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
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) {
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) => {
@ -2848,11 +2934,31 @@ async function loadBookList() {
if (b.last_read) return 1;
return b.uploaded_at.localeCompare(a.uploaded_at);
});
_saveBookMeta(decrypted); // persist for offline
renderBookList(decrypted);
} catch (e) {
// 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>`;
}
}
}
const _brokenBooks = new Set();
@ -3380,12 +3486,21 @@ async function openBook(bookId) {
}
// Restore scroll position — must happen BEFORE auto-save timer is started
try {
let fraction = 0, anchor = '';
try {
const progressRes = await fetch('/books/');
const allBooks = await progressRes.json();
const bookData = allBooks.find(b => b.id === bookId);
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
const anchor = bookData ? (bookData.position_anchor || '') : '';
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;
if (isPdf && readerSettings.pdfPaginated) {
if (fraction > 0 && pdfTotalPages > 1)
@ -3532,6 +3647,9 @@ async function saveReaderProgress() {
_currentPositionAnchor = anchor;
}
// Always update local cache so offline re-opens land at the right position
_updateCachedBookProgress(currentBookId, fraction, anchor);
// Cache for sendBeacon on unload
_lastProgressBeacon = {
url: `/books/${currentBookId}/progress/`,
@ -4117,6 +4235,7 @@ async function loadBookmarks(bookId) {
const res = await fetch(`/books/${bookId}/bookmarks/`);
const {ct, iv} = await res.json();
if (ct) {
_saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
@ -4124,9 +4243,19 @@ async function loadBookmarks(bookId) {
currentBookmarks = [];
}
} catch (e) {
// 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 = [];
}
}
}
async function saveBookmarks() {
if (!currentBookId) return;
@ -4137,6 +4266,7 @@ async function saveBookmarks() {
const body = JSON.stringify({ct: ciphertext, iv});
const url = `/books/${currentBookId}/bookmarks/`;
_lastBookmarkBeacon = {url, body};
_saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
@ -4435,6 +4565,7 @@ async function loadHighlights(bookId) {
const res = await fetch(`/books/${bookId}/highlights/`);
const {ct, iv} = await res.json();
if (ct) {
_saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
@ -4443,8 +4574,19 @@ async function loadHighlights(bookId) {
}
applyHighlightsToContent();
} catch (e) {
// 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();
}
}
async function saveHighlights() {
@ -4456,6 +4598,7 @@ async function saveHighlights() {
const body = JSON.stringify({ct: ciphertext, iv});
const url = `/books/${currentBookId}/highlights/`;
_lastHighlightBeacon = {url, body};
_saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},