From bfa0439aa142bd4458ad835543930f73a5d5e31a Mon Sep 17 00:00:00 2001 From: marwin Date: Fri, 29 May 2026 21:14:40 +0200 Subject: [PATCH] =?UTF-8?q?Books:=20vollst=C3=A4ndiger=20Offline-Modus=20f?= =?UTF-8?q?=C3=BCr=20EPUB-Reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 + static/css/app.css | 13 ++++ static/js/app.js | 171 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 7012ed1..1ff60f1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ CLAUDE.md # OS .DS_Store Thumbs.db +antennapod-feeds-2026-03-19.opml +.gitignore +playlist.m3u diff --git a/static/css/app.css b/static/css/app.css index d4c3d86..aef5048 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/js/app.js b/static/js/app.js index 2165f54..0f253a8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2689,13 +2689,23 @@ function restoreFromAnchor(contentEl, anchor) { // --------------------------------------------------------------------------- // Book cache — IndexedDB, evict after 4 weeks of inactivity // --------------------------------------------------------------------------- -const _BOOK_CACHE_STORE = 'books'; -const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000; +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,9 +2934,29 @@ 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) { - if (listEl) listEl.innerHTML = `

Error loading books: ${e.message}

`; + // 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 = `

Error loading books: ${e.message}

`; + } } } @@ -3381,11 +3487,20 @@ async function openBook(bookId) { // Restore scroll position — must happen BEFORE auto-save timer is started 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 || '') : ''; + let fraction = 0, anchor = ''; + try { + const progressRes = await fetch('/books/'); + const allBooks = await progressRes.json(); + 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; 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,7 +4243,17 @@ async function loadBookmarks(bookId) { currentBookmarks = []; } } 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 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,7 +4574,18 @@ async function loadHighlights(bookId) { } applyHighlightsToContent(); } 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 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()},