From ba5555303ef64c5366e9b6ba0dc8a2e8d159eca3 Mon Sep 17 00:00:00 2001 From: Victor239 <12621257+Victor239@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:15:42 +0000 Subject: [PATCH] E-book viewer: track pending annotation uploads per-book in IDB Previously a single 'pending-annot-upload' IDB key was used, so only the last-annotated book's pending upload survived an app kill. With multiple books annotated offline (or across multiple tabs), earlier books' uploads were silently dropped from the queue. A related bug caused stale in-memory state from a previous book to be used on Sync after navigating between books in the same tab, potentially sending the wrong annotations to the wrong book endpoint. Changes: - IDB key is now 'pending-annot-upload:{library_id}/{book_id}/{fmt}', one entry per book, so all books' pending uploads survive independently - New get_all_pending_annot_uploads() uses an IDB cursor range query to retrieve every pending entry - clear_pending_annot_upload() now takes book identity params and a completion callback so the next upload starts only after the IDB delete has committed - _make_annot_upload_done() returns a per-book closure used as the ajax callback, replacing the single _annot_upload_done method - After each successful upload, _upload_next_from_idb() fetches and uploads the next pending entry, draining the queue sequentially - _on_network_restored() no longer requires a book to be open, so pending uploads from other books are flushed even from the homepage - load_book() clears unsynced_amap and the indicator timer/state so stale in-memory state from the previous book is never used --- src/pyj/read_book/db.pyj | 30 +++++++++++---- src/pyj/read_book/ui.pyj | 83 +++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/pyj/read_book/db.pyj b/src/pyj/read_book/db.pyj index 20c7388eab..7025328fe9 100644 --- a/src/pyj/read_book/db.pyj +++ b/src/pyj/read_book/db.pyj @@ -203,18 +203,32 @@ class DB: self.display_error(error_msg, event) def set_pending_annot_upload(self, library_id, book_id, fmt, amap): - entry = {'key': 'pending-annot-upload', 'library_id': library_id, - 'book_id': book_id, 'fmt': fmt, 'amap': amap} + key = f'pending-annot-upload:{library_id}/{book_id}/{fmt}' + entry = {'key': key, 'library_id': library_id, 'book_id': book_id, 'fmt': fmt, 'amap': amap} self.do_op(['objects'], entry, _('Failed to save pending annotation upload'), def(): None;, op='put') - def get_pending_annot_upload(self, proceed): - self.do_op(['objects'], 'pending-annot-upload', - _('Failed to read pending annotation upload'), proceed) + def get_all_pending_annot_uploads(self, proceed): + transaction = self.idb.transaction(['objects']) + store = transaction.objectStore('objects') + key_range = IDBKeyRange.bound('pending-annot-upload:', 'pending-annot-upload:\uffff') + entries = v'[]' + req = store.openCursor(key_range) + req.onsuccess = def(event): + cursor = event.target.result + if cursor: + entries.push(cursor.value) + cursor.continue() + else: + proceed(entries) + req.onerror = def(event): + self.display_error(_('Failed to read pending annotation uploads'), event) + proceed(v'[]') - def clear_pending_annot_upload(self): - self.do_op(['objects'], 'pending-annot-upload', - _('Failed to clear pending annotation upload'), def(): None;, op='delete') + def clear_pending_annot_upload(self, library_id, book_id, fmt, proceed): + key = f'pending-annot-upload:{library_id}/{book_id}/{fmt}' + self.do_op(['objects'], key, + _('Failed to clear pending annotation upload'), proceed or def(): None;, op='delete') def get_book(self, library_id, book_id, fmt, metadata, proceed): fmt = fmt.toUpperCase() diff --git a/src/pyj/read_book/ui.pyj b/src/pyj/read_book/ui.pyj index c076f0835c..74a99b0ccb 100644 --- a/src/pyj/read_book/ui.pyj +++ b/src/pyj/read_book/ui.pyj @@ -253,6 +253,13 @@ class ReadUI: ) def load_book(self, library_id, book_id, fmt, metadata, force_reload): + self.unsynced_amap = None + if self.unsynced_indicator_timer: + window.clearTimeout(self.unsynced_indicator_timer) + self.unsynced_indicator_timer = None + if self.unsynced_indicator_active: + self.unsynced_indicator_active = False + self.view.show_unsynced_indicator(False) self.base_url_data = {'library_id': library_id, 'book_id':book_id, 'fmt':fmt} if not self.db.initialized: self.pending_load = [book_id, fmt, metadata, force_reload] @@ -314,18 +321,42 @@ class ReadUI: self.unsynced_amap = amap self.db.set_pending_annot_upload(library_id, book_id, fmt, amap) self._reschedule_unsynced_indicator() - ajax_send(f'book-update-annotations/{library_id}/{book_id}/{fmt}', amap, self._annot_upload_done.bind(self)) + ajax_send(f'book-update-annotations/{library_id}/{book_id}/{fmt}', amap, + self._make_annot_upload_done(library_id, book_id, fmt)) - def _annot_upload_done(self, end_type, xhr, ev): - if end_type is 'load': - self.unsynced_amap = None - self.db.clear_pending_annot_upload() - if self.unsynced_indicator_timer: - window.clearTimeout(self.unsynced_indicator_timer) - self.unsynced_indicator_timer = None - if self.unsynced_indicator_active: - self.unsynced_indicator_active = False - self.view.show_unsynced_indicator(False) + def _make_annot_upload_done(self, library_id, book_id, fmt): + def done(end_type, xhr, ev): + if end_type is 'load': + def on_cleared(): + if (self.unsynced_amap is not None and self.base_url_data + and self.base_url_data.library_id is library_id + and str(self.base_url_data.book_id) is str(book_id) + and self.base_url_data.fmt is fmt): + self.unsynced_amap = None + if self.unsynced_indicator_timer: + window.clearTimeout(self.unsynced_indicator_timer) + self.unsynced_indicator_timer = None + if self.unsynced_indicator_active: + self.unsynced_indicator_active = False + self.view.show_unsynced_indicator(False) + self._upload_next_from_idb() + self.db.clear_pending_annot_upload(library_id, book_id, fmt, on_cleared) + return done + + def _upload_next_from_idb(self): + self.db.get_all_pending_annot_uploads(def(entries): + if not entries.length: + return + e = entries[0] + if (self.base_url_data + and self.base_url_data.library_id is e.library_id + and str(self.base_url_data.book_id) is str(e.book_id) + and self.base_url_data.fmt is e.fmt): + self.unsynced_amap = e.amap + self._reschedule_unsynced_indicator() + ajax_send(f'book-update-annotations/{e.library_id}/{e.book_id}/{e.fmt}', e.amap, + self._make_annot_upload_done(e.library_id, e.book_id, e.fmt)) + ) def has_unsynced_changes(self): return self.unsynced_indicator_active @@ -344,24 +375,30 @@ class ReadUI: book_id = self.base_url_data.book_id fmt = self.base_url_data.fmt amap = self.unsynced_amap - def done(end_type, xhr, ev): - self._annot_upload_done(end_type, xhr, ev) - callback(end_type is 'load') - ajax_send(f'book-update-annotations/{library_id}/{book_id}/{fmt}', amap, done) + ajax_send(f'book-update-annotations/{library_id}/{book_id}/{fmt}', amap, + self._make_annot_upload_done(library_id, book_id, fmt)) + callback(True) return - # unsynced_amap is None — page may have been killed; check IDB for a surviving entry - self.db.get_pending_annot_upload(def(entry): - if not entry: + # unsynced_amap is None — page may have been killed or this is another book; + # check IDB for surviving entries + self.db.get_all_pending_annot_uploads(def(entries): + if not entries.length: callback(True) return - self.unsynced_amap = entry.amap - self._reschedule_unsynced_indicator() - self.upload_pending_annotations(callback) + e = entries[0] + if (self.base_url_data + and self.base_url_data.library_id is e.library_id + and str(self.base_url_data.book_id) is str(e.book_id) + and self.base_url_data.fmt is e.fmt): + self.unsynced_amap = e.amap + self._reschedule_unsynced_indicator() + ajax_send(f'book-update-annotations/{e.library_id}/{e.book_id}/{e.fmt}', e.amap, + self._make_annot_upload_done(e.library_id, e.book_id, e.fmt)) + callback(True) ) def _on_network_restored(self): - if self.base_url_data: - self.upload_pending_annotations(def(ok): None;) + self.upload_pending_annotations(def(ok): None;) def annotations_synced(self, amap): library_id = self.base_url_data.library_id