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
This commit is contained in:
Victor239 2026-03-22 14:15:42 +00:00
parent 215caf3525
commit ba5555303e
2 changed files with 82 additions and 31 deletions

View File

@ -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()

View File

@ -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