Books: offline-Fortschritt wird nicht überschrieben (SW v12)
- _saveBookMeta vergleicht local vs. Server last_read — neuere lokale Daten (offline gelesen) überleben den nächsten Server-Abruf - loadBookList pusht beim Online-Werden automatisch lokal neueren Fortschritt an den Server bevor die Liste gerendert wird - saveBookmarks/saveHighlights markieren fehlgeschlagene Saves als pending sync (localStorage); beim nächsten online-Event werden alle pending Annotationen zuerst gepusht, dann erst die Buchliste geladen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5d69edc57e
commit
b097eea600
2 changed files with 78 additions and 6 deletions
|
|
@ -2788,10 +2788,23 @@ async function _saveBookMeta(books) {
|
||||||
try {
|
try {
|
||||||
const db = await _openBookCacheDb();
|
const db = await _openBookCacheDb();
|
||||||
const ids = new Set(books.map(b => String(b.id)));
|
const ids = new Set(books.map(b => String(b.id)));
|
||||||
|
// Read existing local entries to detect offline-modified progress
|
||||||
|
const existing = await new Promise(resolve => {
|
||||||
|
const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll();
|
||||||
|
req.onsuccess = e => resolve(Object.fromEntries((e.target.result || []).map(b => [b.id, b])));
|
||||||
|
req.onerror = () => resolve({});
|
||||||
|
});
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
|
const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
|
||||||
const store = tx.objectStore(_BOOK_META_STORE);
|
const store = tx.objectStore(_BOOK_META_STORE);
|
||||||
for (const b of books) store.put(b);
|
for (const b of books) {
|
||||||
|
const loc = existing[b.id];
|
||||||
|
// Preserve local progress when it's newer than what the server returned
|
||||||
|
const localNewer = loc?.last_read && (!b.last_read || loc.last_read > b.last_read);
|
||||||
|
store.put(localNewer
|
||||||
|
? {...b, scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor, last_read: loc.last_read}
|
||||||
|
: b);
|
||||||
|
}
|
||||||
store.openCursor().onsuccess = e => {
|
store.openCursor().onsuccess = e => {
|
||||||
const cursor = e.target.result;
|
const cursor = e.target.result;
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
|
|
@ -2860,6 +2873,43 @@ async function _loadAnnotationsCache(bookId, type) {
|
||||||
} catch { return 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 {}
|
||||||
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
|
@ -2945,6 +2995,23 @@ async function loadBookList() {
|
||||||
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.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});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Push any locally-cached progress that's newer than the server returned
|
||||||
|
const _localMeta = await _loadCachedBookMeta();
|
||||||
|
const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b]));
|
||||||
|
for (const b of decrypted) {
|
||||||
|
const loc = _localById[b.id];
|
||||||
|
if (loc?.last_read && (!b.last_read || loc.last_read > b.last_read)) {
|
||||||
|
fetch(`/books/${b.id}/progress/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||||
|
body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}),
|
||||||
|
}).catch(() => {});
|
||||||
|
b.scroll_fraction = loc.scroll_fraction;
|
||||||
|
b.position_anchor = loc.position_anchor;
|
||||||
|
b.last_read = loc.last_read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
decrypted.sort((a, b) => {
|
decrypted.sort((a, b) => {
|
||||||
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
|
||||||
if (a.last_read) return -1;
|
if (a.last_read) return -1;
|
||||||
|
|
@ -4302,7 +4369,9 @@ async function saveBookmarks() {
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
bookmarksDirty = false;
|
bookmarksDirty = false;
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
_markPendingSync(currentBookId, 'bookmarks');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBookmark() {
|
function addBookmark() {
|
||||||
|
|
@ -4634,7 +4703,9 @@ async function saveHighlights() {
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
highlightsDirty = false;
|
highlightsDirty = false;
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
_markPendingSync(currentBookId, 'highlights');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _highlightSaveDebounce = null;
|
let _highlightSaveDebounce = null;
|
||||||
|
|
@ -5089,9 +5160,10 @@ function openRadioSidebar() {
|
||||||
showOfflineBanner(true);
|
showOfflineBanner(true);
|
||||||
if (IS_AUTHENTICATED) showTab('books');
|
if (IS_AUTHENTICATED) showTab('books');
|
||||||
});
|
});
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', async () => {
|
||||||
showOfflineBanner(false);
|
showOfflineBanner(false);
|
||||||
loadBookList(); // sync any queued changes
|
await _syncPendingAnnotations(); // push offline bookmark/highlight changes first
|
||||||
|
loadBookList(); // syncs progress and refreshes list
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show banner immediately if already offline at startup
|
// Show banner immediately if already offline at startup
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v11';
|
const CACHE = 'diora-v12';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue