Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_progress_sync.lua
T
neon edc2a2ccfc feat(reader): make KOReader plugin sync non-blocking and resumable
Lifecycle handlers previously blocked on network work, so closing or suspending a book stalled the
device on slow or offline links. Persist snapshots first and drain them through a lifecycle outbox
with connected-state gates and phase-aware retries, so the UI stays responsive and pending work
survives restarts.

Move persistence behind a state manager with atomic flushes, and chunk sweeps, bulk downloads, and
annotation exchange through a background queue with checkpoints and transfer policies, so large
libraries make progress incrementally instead of restarting from scratch after an interruption.

Server side, add bulk manifest and page stats endpoints with batched queries and catalog query
filtering so the plugin can reconcile many books per round trip rather than one request per book.
2026-07-28 02:06:50 +00:00

393 lines
13 KiB
Lua

--[[--
Live progress sync mixin for the BookOrbit plugin (kosync mirror).
Push/pull of reading progress with conflict strategies and a 25 second
debounce, the periodic page-turn push, and the progress reconciliation that
runs before a manual book sync. Installed onto the plugin controller as
regular methods.
]]
local ConfirmBox = require("ui/widget/confirmbox")
local Device = require("device")
local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
local Math = require("optmath")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local time = require("ui/time")
local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")
local API_CALL_DEBOUNCE_DELAY = time.s(25)
-- Assigned from the plugin class on install; shared with the menu module.
local SYNC_STRATEGY
local ProgressSync = {}
local function showSyncError()
UIManager:show(InfoMessage:new{
text = _("Something went wrong when syncing to BookOrbit, please check your network connection and try again later."),
timeout = 3,
})
end
function ProgressSync:getLastPercent()
if self.ui.document.info.has_pages then
return Math.roundPercent(self.ui.paging:getLastPercent())
else
return Math.roundPercent(self.ui.rolling:getLastPercent())
end
end
function ProgressSync:getLastProgress()
if self.ui.document.info.has_pages then
return self.ui.paging:getLastProgress()
else
return self.ui.rolling:getLastProgress()
end
end
-- Always the binary partial MD5: BookOrbit matches on the scanner-computed
-- partial MD5 of the file, so the filename checksum method does not exist here.
function ProgressSync:getDocumentDigest()
if not self.ui or not self.ui.document then return nil end
local doc_settings = self.ui.doc_settings
local digest = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
if digest then return digest end
local file = self.ui.document.file
if not file then return nil end
local ok, computed = pcall(util.partialMD5, file)
if not ok or not computed then return nil end
if doc_settings then
doc_settings:saveSetting("partial_md5_checksum", computed)
end
return computed
end
function ProgressSync:syncToProgress(progress, percentage_fallback)
logger.dbg("BookOrbit: syncing to progress", progress, "percent", percentage_fallback)
local has_position = progress ~= nil and progress ~= ""
if not has_position and percentage_fallback then
-- No exact position (push came from web/audio sync); jump by percentage.
-- kosync percentage is 0-1; GotoPercent expects 0-100.
self.ui:handleEvent(Event:new("GotoPercent", percentage_fallback * 100))
return
end
if self.ui.document.info.has_pages then
self.ui:handleEvent(Event:new("GotoPage", tonumber(progress)))
else
self.ui:handleEvent(Event:new("GotoXPointer", progress))
end
end
function ProgressSync:remoteProgressIsNewer(body, local_percentage)
local local_timestamp = self.last_page_turn_timestamp or 0
if body.timestamp ~= nil then
if local_timestamp > 0 then
return body.timestamp > local_timestamp
end
return body.percentage > local_percentage
end
return body.percentage > local_percentage
end
function ProgressSync:applyRemoteProgress(body, on_done)
self:syncToProgress(body.progress, body.percentage)
if on_done then
UIManager:scheduleIn(0.1, function()
on_done(false)
end)
end
end
function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return false
end
if self:getDocumentDigest() ~= digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return false
end
local client = self:newClient()
local body, err = client:getProgress(digest)
self.pull_timestamp = UIManager:getElapsedTimeSinceBoot()
if not body then
logger.dbg("BookOrbit: progress check before book sync failed:", err)
UIManager:show(InfoMessage:new{ text = _("Could not check latest progress. Syncing book data without changing progress."), timeout = 4 })
on_done(true)
return true
end
if not body.percentage then
on_done(false)
return true
end
if body.device == Device.model and body.device_id == self.device_id then
on_done(false)
return true
end
body.percentage = Math.roundPercent(body.percentage)
local local_progress = self:getLastProgress()
local local_percentage = self:getLastPercent()
if local_percentage == body.percentage or body.progress == local_progress then
on_done(false)
return true
end
local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then
self:applyRemoteProgress(body, on_done)
return true
elseif strategy == SYNC_STRATEGY.PROMPT then
local template = remote_newer and _("Sync to latest location %1% from device '%2' before uploading this book?")
or _("Sync to previous location %1% from device '%2' before uploading this book?")
UIManager:show(ConfirmBox:new{
text = T(template, Math.round(body.percentage * 100), body.device),
ok_callback = function()
self:applyRemoteProgress(body, on_done)
end,
cancel_callback = function()
on_done(remote_newer)
end,
})
return true
elseif remote_newer then
UIManager:show(InfoMessage:new{
text = _("BookOrbit has newer progress. Syncing book data without changing progress."),
timeout = 4,
})
on_done(true)
return true
else
on_done(false)
return true
end
end
function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
if not self:isLoggedIn() then
if interactive then self:promptLogin() end
return
end
if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then
logger.dbg("BookOrbit: push debounced")
return
end
if ensure_networking and NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(function()
self:updateProgress(ensure_networking, interactive, on_suspend)
end)
end) then
return
end
local digest = self:getDocumentDigest()
if not digest then return end
local client = self:newClient()
local body, err = client:updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time())
if interactive then
if body then
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 })
if not on_suspend then
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end
else
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
showSyncError()
end
elseif not body then
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
logger.dbg("BookOrbit: push failed:", err)
elseif not on_suspend then
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end
if on_suspend and Device:hasWifiManager() then
NetworkMgr:disableWifi()
end
self.push_timestamp = now
end
function ProgressSync:getProgress(ensure_networking, interactive)
if not self:isLoggedIn() then
if interactive then self:promptLogin() end
return
end
if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
logger.dbg("BookOrbit: pull debounced")
return
end
if ensure_networking and NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(function()
self:getProgress(ensure_networking, interactive)
end)
end) then
return
end
local digest = self:getDocumentDigest()
if not digest then return end
local client = self:newClient()
local body, err = client:getProgress(digest)
self.pull_timestamp = now
if not body then
if interactive then showSyncError() end
if self.recordSyncError then
self:recordSyncError("progress_pull", err)
end
logger.dbg("BookOrbit: pull failed:", err)
return
end
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_pull")
else
self:maybeCheckForUpdate(false)
end
if not body.percentage then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No progress found for this document."), timeout = 3 })
end
return
end
if body.device == Device.model and body.device_id == self.device_id then
if interactive then
UIManager:show(InfoMessage:new{ text = _("Latest progress is coming from this device."), timeout = 3 })
end
return
end
body.percentage = Math.roundPercent(body.percentage)
local progress = self:getLastProgress()
local percentage = self:getLastPercent()
if percentage == body.percentage or body.progress == progress then
if interactive then
UIManager:show(InfoMessage:new{ text = _("The progress has already been synchronized."), timeout = 3 })
end
return
end
if interactive then
self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
return
end
local self_older
if body.timestamp ~= nil then
self_older = (body.timestamp > self.last_page_turn_timestamp)
else
self_older = (body.percentage > percentage)
end
local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then
self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
elseif strategy == SYNC_STRATEGY.PROMPT then
local template = self_older and _("Sync to latest location %1% from device '%2'?")
or _("Sync to previous location %1% from device '%2'?")
UIManager:show(ConfirmBox:new{
text = T(template, Math.round(body.percentage * 100), body.device),
ok_callback = function()
self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
end,
})
end
end
function ProgressSync:schedulePeriodicPush()
UIManager:unschedule(self.periodic_push_task)
-- A sizable delay debounces nicely while skimming.
UIManager:scheduleIn(10, self.periodic_push_task)
self.periodic_push_scheduled = true
end
function ProgressSync:getSyncPeriod()
if not self.settings.auto_sync then
return _("Not available")
end
local period = self.settings.pages_before_update
if period and period > 0 then
return period
end
return _("Never")
end
function ProgressSync:setPagesBeforeUpdate(value)
self.settings.pages_before_update = value
end
function ProgressSync.install(BookOrbit)
SYNC_STRATEGY = BookOrbit.SYNC_STRATEGY
for name, fn in pairs(ProgressSync) do
if name ~= "install" then
BookOrbit[name] = fn
end
end
end
return ProgressSync