fix(koreader): offer a further place before a fresh copy pushes its own

A device that had not weighed its open book against BookOrbit since
opening dated its place by its last page turn. One turn from a fresh
download's cover made another device's further place look older, so
Sync now pushed the cover over it without asking, and the phone then
reopened the book at its title page.

Until the open book's place has been compared with BookOrbit's (a pull
or push went through, or Jump or Stay was answered), a further BookOrbit
place now also counts as newer, so Sync now and pulls offer Jump instead
of overwriting it. Once compared, page-turn times decide as before, and a
reset stamped after the last page turn still prompts.

Ref #1806
This commit is contained in:
neon
2026-10-19 23:03:05 +00:00
parent 6440281e65
commit 1a2ffb68c0
4 changed files with 205 additions and 13 deletions
@@ -337,10 +337,12 @@ local function onAction(page, action)
elseif kind == "jump" then elseif kind == "jump" then
local remote = page.book_data.remote local remote = page.book_data.remote
page:close() page:close()
plugin:noteProgressCompared(page.book_data.digest)
plugin:syncToProgress(remote.progress, remote.percentage) plugin:syncToProgress(remote.progress, remote.percentage)
plugin:recordSyncSuccess("progress_pull", _("Progress pulled")) plugin:recordSyncSuccess("progress_pull", _("Progress pulled"))
elseif kind == "stay" then elseif kind == "stay" then
page:close() page:close()
plugin:noteProgressCompared(page.book_data.digest)
end end
return true return true
end end
@@ -133,12 +133,32 @@ function ProgressSync:remoteProgressIsNewer(body, local_percentage)
if body.timestamp ~= nil then if body.timestamp ~= nil then
if local_timestamp > 0 then if local_timestamp > 0 then
return body.timestamp > local_timestamp return body.timestamp > local_timestamp
or self:furtherBeforeCompared(body.percentage, local_percentage)
end end
return body.percentage > local_percentage return body.percentage > local_percentage
end end
return body.percentage > local_percentage return body.percentage > local_percentage
end end
-- A page turn dates this device's place, but until that place has been weighed
-- against BookOrbit's since the book opened it can be a fresh download's cover
-- or a copy left days behind. One turn from there must not make another
-- device's further place look older and get overwritten (#1806).
function ProgressSync:furtherBeforeCompared(remote_percentage, local_percentage)
return remote_percentage > local_percentage and not self:progressComparedSinceOpen()
end
-- Whether the open book's place has been weighed against BookOrbit's since it
-- opened: a pull or push went through, or the reader answered Jump or Stay.
function ProgressSync:progressComparedSinceOpen()
local compared = self.progress_compared_digest
return compared ~= nil and compared == self:getDocumentDigest()
end
function ProgressSync:noteProgressCompared(digest)
self.progress_compared_digest = digest or self:getDocumentDigest()
end
function ProgressSync:applyRemoteProgress(body, on_done) function ProgressSync:applyRemoteProgress(body, on_done)
self:syncToProgress(body.progress, body.percentage) self:syncToProgress(body.progress, body.percentage)
if on_done then if on_done then
@@ -171,12 +191,14 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
end end
if not body.percentage then if not body.percentage then
self:noteProgressCompared(digest)
on_done(false) on_done(false)
return true return true
end end
if body.device == Device.model and body.device_id == self.device_id if body.device == Device.model and body.device_id == self.device_id
and (self.last_page_turn_timestamp or 0) > 0 then and (self.last_page_turn_timestamp or 0) > 0 then
self:noteProgressCompared(digest)
on_done(false) on_done(false)
return true return true
end end
@@ -186,22 +208,28 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
local local_percentage = self:getLastPercent() local local_percentage = self:getLastPercent()
if local_percentage == body.percentage or body.progress == local_progress then if local_percentage == body.percentage or body.progress == local_progress then
self:noteProgressCompared(digest)
on_done(false) on_done(false)
return true return true
end end
local remote_newer = self:remoteProgressIsNewer(body, local_percentage) local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then if strategy == SYNC_STRATEGY.PROMPT then
self:applyRemoteProgress(body, on_done) -- Weighed once answered: a Stay, then reading on, makes this place the newer one.
return true
elseif strategy == SYNC_STRATEGY.PROMPT then
openPrompt(self, body, remote_newer, true, function() openPrompt(self, body, remote_newer, true, function()
self:noteProgressCompared(digest)
self:applyRemoteProgress(body, on_done) self:applyRemoteProgress(body, on_done)
end, function() end, function()
self:noteProgressCompared(digest)
on_done(remote_newer) on_done(remote_newer)
end) end)
return true return true
end
self:noteProgressCompared(digest)
if strategy == SYNC_STRATEGY.SILENT then
self:applyRemoteProgress(body, on_done)
return true
elseif remote_newer then elseif remote_newer then
Toast.show(_("Kept your place. BookOrbit is further ahead."), { icon = "info", timeout = 4 }) Toast.show(_("Kept your place. BookOrbit is further ahead."), { icon = "info", timeout = 4 })
on_done(true) on_done(true)
@@ -231,7 +259,9 @@ function ProgressSync:pushProgressAfterBookSync(digest)
if not self.ui or not self.ui.document or self:getDocumentDigest() ~= digest then return end if not self.ui or not self.ui.document or self:getDocumentDigest() ~= digest then return end
local body, err = self:newClient():updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time()) local body, err = self:newClient():updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time())
self.push_timestamp = UIManager:getElapsedTimeSinceBoot() self.push_timestamp = UIManager:getElapsedTimeSinceBoot()
if not body then if body then
self:noteProgressCompared(digest)
else
logger.dbg("BookOrbit: progress push after book sync failed:", err) logger.dbg("BookOrbit: progress push after book sync failed:", err)
end end
end end
@@ -306,6 +336,7 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
NetworkMgr:disableWifi() NetworkMgr:disableWifi()
end end
if body then self:noteProgressCompared(digest) end
if body and self.book_sheet then self:fetchRemoteProgress(digest, nil, true) end if body and self.book_sheet then self:fetchRemoteProgress(digest, nil, true) end
self.push_timestamp = now self.push_timestamp = now
@@ -367,6 +398,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
end end
if not body.percentage then if not body.percentage then
self:noteProgressCompared(digest)
if interactive then if interactive then
Toast.show(_("BookOrbit has no position for this book yet"), { icon = "info" }) Toast.show(_("BookOrbit has no position for this book yet"), { icon = "info" })
end end
@@ -377,6 +409,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
-- Explicit pulls and opens before any page turn must still recover that position. -- Explicit pulls and opens before any page turn must still recover that position.
if not interactive and body.device == Device.model and body.device_id == self.device_id if not interactive and body.device == Device.model and body.device_id == self.device_id
and (self.last_page_turn_timestamp or 0) > 0 then and (self.last_page_turn_timestamp or 0) > 0 then
self:noteProgressCompared(digest)
return return
end end
@@ -385,6 +418,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local percentage = self:getLastPercent() local percentage = self:getLastPercent()
if percentage == body.percentage or body.progress == progress then if percentage == body.percentage or body.progress == progress then
self:noteProgressCompared(digest)
if interactive then if interactive then
Toast.show(_("Already in sync")) Toast.show(_("Already in sync"))
end end
@@ -392,6 +426,7 @@ function ProgressSync:getProgress(ensure_networking, interactive)
end end
if interactive then if interactive then
self:noteProgressCompared(digest)
self:syncToProgress(body.progress, body.percentage) self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled")) self:recordSyncSuccess("progress_pull", _("Progress pulled"))
@@ -403,25 +438,32 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local self_older local self_older
if body.timestamp ~= nil then if body.timestamp ~= nil then
self_older = (body.timestamp > self.last_page_turn_timestamp) self_older = (body.timestamp > self.last_page_turn_timestamp)
or self:furtherBeforeCompared(body.percentage, percentage)
else else
self_older = (body.percentage > percentage) self_older = (body.percentage > percentage)
end end
local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.PROMPT then
openPrompt(self, body, self_older, false, function()
self:noteProgressCompared(digest)
self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
showMoved(body)
end, function()
self:noteProgressCompared(digest)
end)
return
end
self:noteProgressCompared(digest)
if strategy == SYNC_STRATEGY.SILENT then if strategy == SYNC_STRATEGY.SILENT then
self:syncToProgress(body.progress, body.percentage) self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled")) self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end end
showMoved(body) showMoved(body)
elseif strategy == SYNC_STRATEGY.PROMPT then
openPrompt(self, body, self_older, false, function()
self:syncToProgress(body.progress, body.percentage)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
showMoved(body)
end)
end end
end end
@@ -85,6 +85,8 @@ local BookOrbit = WidgetContainer:extend{
pull_timestamp = nil, pull_timestamp = nil,
last_page = nil, last_page = nil,
last_page_turn_timestamp = nil, last_page_turn_timestamp = nil,
-- Digest of the open book once its place was weighed against BookOrbit's.
progress_compared_digest = nil,
settings = nil, settings = nil,
} }
@@ -0,0 +1,146 @@
-- A device that opens a book it has not synced since opening (a fresh download
-- at its cover, or a copy left days behind) used to date its place by its last
-- page turn. One turn made another device's further place look older, so Sync
-- now pushed the cover over it without asking (#1806). Until the open book's
-- place has been weighed against BookOrbit's, a further BookOrbit place counts
-- as newer; once weighed, page-turn times decide as before.
package.loaded["gettext"] = function(text) return text end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index) return tostring(values[tonumber(index)]) end))
end,
}
local prompts = {}
package.loaded["bookorbit_book_sheet"] = {
openPrompt = function(_plugin, prompt) table.insert(prompts, prompt) end,
}
package.loaded["bookorbit_toast"] = { show = function() end, error = function() end }
package.loaded["device"] = { model = "Kindle" }
package.loaded["ui/event"] = { new = function(_, name, value) return { name = name, value = value } end }
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["optmath"] = { roundPercent = function(value) return value end, round = function(value) return value end }
package.loaded["ui/network/manager"] = { willRerunWhenConnected = function() return false end }
package.loaded["ui/uimanager"] = {
show = function() end,
nextTick = function(_, fn) fn() end,
scheduleIn = function(_, _, fn) fn() end,
unschedule = function() end,
getElapsedTimeSinceBoot = function() return 10000 end,
}
package.loaded["logger"] = { dbg = function() end, warn = function() end }
package.loaded["ui/time"] = { s = function(value) return value end }
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
package.loaded["bookorbit_state_manager"] = { repairFileIdentity = function() end }
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local ProgressSync = require("bookorbit_progress_sync")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local DIGEST = "digest:/tmp/book.epub"
-- The phone read to 46% before this device turned its first page.
local PHONE = { percentage = 0.46, progress = "/body/DocFragment[20]/body/p[56]", device = "iPhone", device_id = "phone", timestamp = 20000 }
local PAGE_TURN = 30000
-- A fresh download, opened at its cover and turned one page.
local function makePlugin(remote, local_percent)
prompts = {}
local plugin = { SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 } }
ProgressSync.install(plugin)
plugin.pull_timestamp, plugin.push_timestamp = 0, 0
plugin.device_id = "kindle"
-- Defaults as shipped: forward prompts, backward is disabled, auto sync off.
plugin.settings = { sync_forward = 1, sync_backward = 3, auto_sync = false }
plugin.last_page_turn_timestamp = PAGE_TURN
plugin.pushes, plugin.moves = {}, {}
plugin.ui = { document = { file = "/tmp/book.epub", info = { has_pages = false } } }
plugin.ui.handleEvent = function(_, event) table.insert(plugin.moves, event) end
plugin.local_percent = local_percent or 0.008
plugin.getLastProgress = function() return "/body/DocFragment[2]/body/h1/img" end
plugin.getLastPercent = function() return plugin.local_percent end
plugin.isLoggedIn = function() return true end
plugin.requestUpdateCheck = function() end
plugin.recordSyncSuccess = function() end
plugin.recordSyncError = function() end
plugin.newClient = function()
return {
getProgress = function() return remote end,
updateProgress = function(_, digest, percent)
table.insert(plugin.pushes, { digest = digest, percent = percent })
return {}
end,
}
end
return plugin
end
local function syncNow(plugin)
plugin:syncBookThenProgress(DIGEST, function(_skip_progress, after_sync) after_sync() end)
end
-- Sync now from the cover asks before anything moves, and pushes nothing.
local plugin = makePlugin(PHONE)
syncNow(plugin)
assertEqual(#prompts, 1, "a further place on BookOrbit is offered, not overwritten")
assertEqual(prompts[1].newer, true, "and it is offered as the newer place")
assertEqual(#plugin.pushes, 0, "nothing is pushed while the question is open")
-- Taking it moves the reader there before the position goes up.
prompts[1].on_jump()
assertEqual(plugin.moves[1].name, "GotoXPointer", "Jump moves the reader to BookOrbit's place")
assertEqual(#plugin.pushes, 1, "then the position is pushed")
-- Staying pushes nothing; it settles the question, so reading on from here
-- makes this place the newer one, as it did before.
plugin = makePlugin(PHONE)
syncNow(plugin)
prompts[1].on_stay()
assertEqual(#plugin.pushes, 0, "declining the further place leaves the server alone")
plugin.last_page_turn_timestamp = PAGE_TURN + 60
plugin.local_percent = 0.012
syncNow(plugin)
assertEqual(#prompts, 1, "after Stay and another page, Sync now does not ask again")
assertEqual(#plugin.pushes, 1, "and this device's place goes up")
-- Once weighed in this opening, page-turn times decide as before: a later
-- turn beats an older BookOrbit place without a question.
plugin = makePlugin(PHONE)
plugin:noteProgressCompared(DIGEST)
syncNow(plugin)
assertEqual(#prompts, 0, "a weighed place is not asked about again")
assertEqual(#plugin.pushes, 1, "the newer page turn is pushed")
-- Weighing is per book: another book's sync does not count for this one.
plugin = makePlugin(PHONE)
plugin:noteProgressCompared("digest:/tmp/other.epub")
syncNow(plugin)
assertEqual(#prompts, 1, "another book's comparison does not date this place")
-- A place behind this device is still kept and pushed without a question.
plugin = makePlugin({ percentage = 0.1, progress = "/body/DocFragment[5]/body", device = "iPhone", device_id = "phone", timestamp = 20000 }, 0.3)
syncNow(plugin)
assertEqual(#prompts, 0, "BookOrbit behind this device asks nothing")
assertEqual(#plugin.pushes, 1, "and this device's further place is pushed")
-- A pull after a page turn offers the further place instead of dropping it.
plugin = makePlugin(PHONE)
plugin:getProgress(false, false)
assertEqual(#prompts, 1, "a pull from the cover offers the further place")
assertEqual(prompts[1].newer, true, "as the newer place")
prompts[1].on_stay()
assertEqual(plugin:progressComparedSinceOpen(), true, "staying settles the question for this opening")
-- The This book sheet's chip agrees.
plugin = makePlugin(PHONE)
assertEqual(plugin:remoteProgressIsNewer(PHONE, 0.008), true, "the sheet shows BookOrbit ahead before weighing")
plugin:noteProgressCompared(DIGEST)
assertEqual(plugin:remoteProgressIsNewer(PHONE, 0.008), false, "and page-turn times once weighed")
print("bookorbit_fresh_device_progress_test: ok")