Compare commits

...
2 Commits
Author SHA1 Message Date
john-okeefe 656505eda7 fix(sync): report interactive push/pull failures when offline
An interactive pull could exit without any feedback while push showed
'Failed to push progress', from two silent exits on the pull path:

- pulling an unlinked book delegated to a non-interactive push whose
  failure produced no UI (and could be swallowed by the 25s debounce)
- when the device is connected at the IP level but has no internet,
  NetworkMgr:willRerunWhenOnline drops the callback with neither a
  rerun nor any UI (it only guarantees isConnected)

Now the connected-but-offline state is detected up front and reported
('No internet connection. Progress was not pulled/pushed.'), resolve
failures notify interactively, pulling with progress sync disabled
explains itself instead of silently returning, and the generic pull
failure message mentions the network connection like push does.
2026-08-22 09:57:26 -04:00
john-okeefe 9467bdb2bd fix(sync): link fresh books via read-only SHA-256 resolve before first pull
Downloading a book from OPDS and opening it for the first time left the
plugin without a cached bookhoard UUID, so it bootstrapped the book's
identity by pushing once. That push carried the device's first-page
position, which the server stored as a real progress update for a book
that could be mid-read from another source — tripping the cross-source
conflict detector and clobbering genuine progress before the first pull
could deliver it. Until the conflict was resolved on the web, the device
could not pull the correct progress at all.

- onReaderReady now always pulls first; a pull no longer delegates to a
  push to learn the book's identity
- new _linkBookThenPull: resolves the UUID with a read-only
  GET /api/sync/koreader/resolve?sha256= call (BookhoardAPI:resolveBook),
  caches it in the document sidecar, then performs the pull — the
  device's position is never transmitted
- the legacy push-bootstrap survives only as a background fallback for
  books the server does not know by hash (404, sideloaded); interactive
  pulls are told to push explicitly instead

Requires the server-side resolve endpoint (bookhoard feat(sync): add
read-only KOReader book resolve endpoint).
2026-08-22 09:57:16 -04:00
2 changed files with 132 additions and 27 deletions
+7
View File
@@ -126,6 +126,13 @@ function BookhoardAPI:syncProgress(book_data, sync_mode)
}, PROGRESS_TIMEOUTS) }, PROGRESS_TIMEOUTS)
end end
-- Read-only SHA-256 → book UUID lookup. Used to link a freshly downloaded
-- book WITHOUT pushing progress (a push would transmit the device's
-- first-page position and clobber/conflict with real server progress).
function BookhoardAPI:resolveBook(sha256)
return self:_request("GET", "/api/sync/koreader/resolve?sha256=" .. sha256, nil, PROGRESS_TIMEOUTS)
end
function BookhoardAPI:getMetadata(uuid) function BookhoardAPI:getMetadata(uuid)
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS) return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
end end
+125 -27
View File
@@ -121,17 +121,13 @@ end
function Bookhoard:onReaderReady() function Bookhoard:onReaderReady()
if self.settings.auto_sync then if self.settings.auto_sync then
UIManager:nextTick(function() UIManager:nextTick(function()
-- On the first open of a freshly downloaded book there is no cached -- Pull-first on every open. A freshly downloaded book has no cached
-- bookhoard UUID yet (it is only learned from a successful push -- bookhoard UUID yet; getProgress resolves it by SHA-256 with a
-- response). Pulling now would just fail with "Push progress first". -- read-only lookup (no progress push). The previous push-to-
-- Instead, push once to bootstrap identity: the server resolves the -- bootstrap strategy transmitted the device's first-page position
-- book by SHA-256 (format-aware) and returns the UUID, which we then -- to the server on books already mid-read elsewhere, creating a
-- cache. After that, push and pull both work without ordering. -- sync conflict before the first pull could ever happen.
if not self:getBookhoardUUID() then self:getProgress(true, false)
self:updateProgress(true, false)
else
self:getProgress(true, false)
end
end) end)
end end
self:registerEvents() self:registerEvents()
@@ -994,7 +990,15 @@ function Bookhoard:updateProgress(ensure_networking, interactive)
return return
end end
if not self.settings.sync_progress then return end if not self.settings.sync_progress then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Reading progress sync is disabled (see What to sync)."),
timeout = 3,
})
end
return
end
if not self.ui.document then if not self.ui.document then
if interactive then if interactive then
@@ -1011,9 +1015,19 @@ function Bookhoard:updateProgress(ensure_networking, interactive)
return return
end end
if ensure_networking if ensure_networking then
and NetworkMgr:willRerunWhenOnline(function() self:updateProgress(ensure_networking, interactive) end) then -- Same silent-drop case as getProgress: connected but no internet
return -- means willRerunWhenOnline neither reruns nor shows any UI.
if interactive and NetworkMgr:isConnected() and not NetworkMgr:isOnline() then
UIManager:show(InfoMessage:new{
text = _("No internet connection. Progress was not pushed."),
timeout = 3,
})
return
end
if NetworkMgr:willRerunWhenOnline(function() self:updateProgress(ensure_networking, interactive) end) then
return
end
end end
UIManager:scheduleIn(0.5, function() UIManager:scheduleIn(0.5, function()
@@ -1082,30 +1096,53 @@ function Bookhoard:getProgress(ensure_networking, interactive)
return return
end end
if not self.settings.sync_progress then return end if not self.settings.sync_progress then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Reading progress sync is disabled (see What to sync)."),
timeout = 3,
})
end
return
end
local now = UIManager:getElapsedTimeSinceBoot() local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
return return
end end
if ensure_networking if ensure_networking then
and NetworkMgr:willRerunWhenOnline(function() self:getProgress(ensure_networking, interactive) end) then -- willRerunWhenOnline silently drops the action (no rerun, no UI)
return -- when the device is connected at the IP level but has no internet
-- access: it only guarantees isConnected, and in that state the
-- framework re-connects without ever invoking the callback. Catch
-- that state up front so an interactive pull never no-ops silently.
if interactive and NetworkMgr:isConnected() and not NetworkMgr:isOnline() then
UIManager:show(InfoMessage:new{
text = _("No internet connection. Progress was not pulled."),
timeout = 3,
})
return
end
if NetworkMgr:willRerunWhenOnline(function() self:getProgress(ensure_networking, interactive) end) then
return
end
end end
local book_uuid = self:getBookhoardUUID() local book_uuid = self:getBookhoardUUID()
if not book_uuid then if not book_uuid then
-- No cached UUID yet (first open of a freshly downloaded book). Bootstrap -- No cached UUID yet (first open of a freshly downloaded book). Link
-- it by pushing once: the server resolves the book by SHA-256 and returns -- the book with a read-only SHA-256 lookup, then pull. Progress is
-- the UUID, which the push handler caches. Then retry the pull. -- never pushed here: the device sits on the first page of a book the
-- server may hold mid-read, and pushing that would overwrite/server-
-- conflict the real progress before the pull could deliver it.
if interactive then if interactive then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = _("Linking this book to Bookhoard first…"), text = _("Linking this book to Bookhoard first…"),
timeout = 3, timeout = 3,
}) })
end end
self:_bootstrapUUIDThenPull(interactive) self:_linkBookThenPull(interactive)
return return
end end
@@ -1116,9 +1153,70 @@ function Bookhoard:getProgress(ensure_networking, interactive)
self.pull_timestamp = now self.pull_timestamp = now
end end
-- Establish the bookhoard UUID for the current document by pushing once, then -- Link the current document to Bookhoard WITHOUT pushing progress, then run
-- (once cached) perform the originally-requested pull. Used when a pull is -- the pull. The UUID is learned via a read-only SHA-256 resolve; only when
-- requested before any push has run on a newly downloaded book. -- the server does not know the hash at all (sideloaded book) do we fall back
-- to the legacy push-bootstrap, and only for background (non-interactive)
-- flows — a user-initiated pull must never push this device's position.
function Bookhoard:_linkBookThenPull(interactive)
local file_sha256 = self:getFileSHA256()
if not file_sha256 then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Could not read this file to link it with Bookhoard."),
timeout = 3,
})
end
return
end
UIManager:scheduleIn(0.5, function()
local api = self:getAPI()
local ok, result = api:resolveBook(file_sha256)
UIManager:nextTick(function()
if not self.ui.doc_settings then return end
if ok and type(result) == "table" and result.book_uuid then
self.ui.doc_settings:saveSetting("bookhoard_uuid", result.book_uuid)
self.ui.doc_settings:flush()
logger.dbg("Bookhoard: linked book by SHA-256 to", result.book_uuid)
self:_doGetProgress(interactive)
return
end
if result and result.status == 404 then
-- Genuinely unknown to the server (sideloaded book). The
-- legacy push-bootstrap only ever linked such books via fuzzy
-- title/author matching; keep that as a background attempt,
-- but tell interactive users to push explicitly.
if interactive then
UIManager:show(InfoMessage:new{
text = _("This book is not in your Bookhoard library yet. Push progress to link it."),
timeout = 4,
})
else
self:_bootstrapUUIDThenPull(interactive)
end
return
end
logger.warn("Bookhoard: failed to resolve book by SHA-256")
if interactive then
UIManager:show(InfoMessage:new{
text = _("Could not reach Bookhoard. Check your network connection and try again."),
timeout = 4,
})
end
end)
end)
end
-- Legacy fallback: establish the bookhoard UUID for the current document by
-- pushing once, then (once cached) perform the originally-requested pull.
-- Only reached from background flows when the SHA-256 resolve reported the
-- book as unknown to the server; the push mainly serves fuzzy title/author
-- linking and device alias registration for sideloaded books.
function Bookhoard:_bootstrapUUIDThenPull(interactive) function Bookhoard:_bootstrapUUIDThenPull(interactive)
self:updateProgress(false, false) self:updateProgress(false, false)
UIManager:scheduleIn(2, function() UIManager:scheduleIn(2, function()
@@ -1144,7 +1242,7 @@ function Bookhoard:_doGetProgress(interactive)
if not ok or not result then if not ok or not result then
if interactive then if interactive then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = _("Failed to pull progress."), text = _("Failed to pull progress. Check your network connection."),
timeout = 3, timeout = 3,
}) })
end end