mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4a4b62bbef
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update. Fixes #1497
410 lines
14 KiB
Lua
410 lines
14 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 BookOrbitStateManager = require("bookorbit_state_manager")
|
|
|
|
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 file = self.ui.document.file
|
|
if not file then return nil end
|
|
if self.bookorbit_document_digest and self.bookorbit_document_digest.file == file then
|
|
return self.bookorbit_document_digest.digest
|
|
end
|
|
|
|
local doc_settings = self.ui.doc_settings
|
|
local cached = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
|
|
local ok, computed = pcall(util.partialMD5, file)
|
|
if not ok or not computed then return cached end
|
|
|
|
self.bookorbit_document_digest = {
|
|
file = file,
|
|
digest = computed,
|
|
repaired = cached ~= nil and cached ~= computed,
|
|
}
|
|
if cached ~= computed then
|
|
if doc_settings then
|
|
doc_settings:saveSetting("partial_md5_checksum", computed)
|
|
end
|
|
local repaired, repair_err = pcall(
|
|
BookOrbitStateManager.repairFileIdentity, file, cached, computed)
|
|
if not repaired then
|
|
logger.warn("BookOrbit: could not repair cached file identity:", repair_err)
|
|
end
|
|
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
|