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
|
||||
.DS_Store
|
||||
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-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;
|
||||
|
|
|
|||
155
static/js/app.js
155
static/js/app.js
|
|
@ -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,10 +2934,30 @@ 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,8 +4243,18 @@ 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() {
|
||||
|
|
@ -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()},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue