Files
bookorbit/koreader-plugin/bookorbit.koplugin/main.lua
T
neon e97d54953b fix(reader): reconcile remote progress before manual KOReader book sync
When manually syncing a book, the plugin uploaded progress without checking if another device had written a newer progress state to the server first, causing reading progress to be overwritten and lost.

Fetch the remote progress from the server before starting a manual sync. If a newer progress state is found on the server, prompt the user to apply the remote progress locally first. If the prompt is rejected, proceed with the sync of annotations and reading history while bypassing the progress upload.

Closes #461
2026-07-03 05:52:22 +00:00

1496 lines
52 KiB
Lua

--[[--
BookOrbit Sync plugin.
Live progress sync mirrors the stock kosync plugin (pull on open with
conflict strategies, periodic push every N page turns, push on close and
suspend, 25 second debounce) against BookOrbit's kosync-compatible endpoints.
Closing or suspending additionally snapshots the open book from live memory
(progress, highlights, status/rating, page stats) and uploads it per book.
The full-library sweep is manual-only.
]]
local ConfirmBox = require("ui/widget/confirmbox")
local Device = require("device")
local Dispatcher = require("dispatcher")
local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog")
local Math = require("optmath")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local Notification = require("ui/widget/notification")
local PluginShare = require("pluginshare")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local logger = require("logger")
local md5 = require("ffi/sha2").md5
local time = require("ui/time")
local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitCatalog = require("bookorbit_catalog")
local BookOrbitState = require("bookorbit_state")
local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.1.0"
local SYNC_STRATEGY = {
PROMPT = 1,
SILENT = 2,
DISABLE = 3,
}
local API_CALL_DEBOUNCE_DELAY = time.s(25)
local UPDATE_CHECK_INTERVAL = 24 * 60 * 60
local BookOrbit = WidgetContainer:extend{
name = "bookorbit",
title = _("Login to BookOrbit"),
push_timestamp = nil,
pull_timestamp = nil,
last_page = nil,
last_page_turn_timestamp = nil,
settings = nil,
}
BookOrbit.default_settings = {
settings_version = 1,
server_url = nil,
username = nil,
userkey = nil,
auto_sync = false,
skip_sync_when_offline = false,
annotation_sync = true,
pages_before_update = 10,
sync_forward = SYNC_STRATEGY.PROMPT,
sync_backward = SYNC_STRATEGY.DISABLE,
catalog_view_mode = "mosaic",
catalog_sort = "recently_added",
catalog_grid_cols = 3,
catalog_grid_rows = 3,
catalog_mosaic_show_titles = false,
catalog_recent_searches = {},
catalog_auto_open = "off",
catalog_dashboard_cache = nil,
update_check_last_at = 0,
}
function BookOrbit:init()
self.push_timestamp = 0
self.pull_timestamp = 0
self.last_page = -1
self.last_page_turn_timestamp = 0
self.page_update_counter = 0
self.periodic_push_scheduled = false
self.provision_applied = false
self.periodic_push_task = function()
self.periodic_push_scheduled = false
self.page_update_counter = 0
-- Push only, no pull, no network nagging: relies on the connection
-- being already up, like the stock kosync periodic push.
if self.settings.auto_sync and (self.settings.pages_before_update or 0) > 0 then
self:updateProgress(false, false)
end
end
self.settings = G_reader_settings:readSetting("bookorbit", self.default_settings)
self.device_id = G_reader_settings:readSetting("device_id")
-- Detect settings from the old bookorbit-koplugin, which used the same
-- "bookorbit" key but a different structure. Reset behavior keys to
-- defaults while preserving any existing credentials so users aren't
-- logged out.
if not self.settings.settings_version then
local server_url = self.settings.server_url
local username = self.settings.username
local userkey = self.settings.userkey
local provision_fingerprint = self.settings.provision_fingerprint
self.settings = {}
for k, v in pairs(self.default_settings) do
self.settings[k] = type(v) == "table" and {} or v
end
self.settings.server_url = server_url
self.settings.username = username
self.settings.userkey = userkey
self.settings.provision_fingerprint = provision_fingerprint
G_reader_settings:saveSetting("bookorbit", self.settings)
G_reader_settings:flush()
logger.info("BookOrbit: migrated settings from previous schema")
end
-- v1 settings cleanup: full sweeps are manual-only since 0.2.0.
self.settings.sweep_on_close = nil
self.settings.sweep_on_suspend = nil
if self.settings.pages_before_update == nil then
self.settings.pages_before_update = 10
end
if self.settings.annotation_sync == nil then
self.settings.annotation_sync = true
end
if self.settings.update_check_last_at == nil then
self.settings.update_check_last_at = 0
end
if self.settings.catalog_auto_open == nil then
self.settings.catalog_auto_open = "off"
end
self:applyProvision()
if self.settings.auto_sync and Device:hasSeamlessWifiToggle() and G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
self.settings.auto_sync = false
logger.warn("BookOrbit: auto sync disabled because wifi_enable_action is not turn_on")
end
pcall(BookOrbitMenuPin.ensure)
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
self:onStart()
UIManager:scheduleIn(5, function()
self:maybeCheckForUpdate(false)
end)
end
local PROVISION_FILE = "bookorbit_provision.lua"
-- Applies the connection settings bundled by "Download preconfigured plugin"
-- in BookOrbit web settings. The fingerprint ties one download to one apply:
-- a freshly generated zip always (re)configures, while reinstalling an old
-- one never overrides a logout.
function BookOrbit:applyProvision()
if not self.path then return end
local provision_path = self.path .. "/" .. PROVISION_FILE
local chunk = loadfile(provision_path)
if not chunk then return end
setfenv(chunk, {})
local ok, provision = pcall(chunk)
if not ok or type(provision) ~= "table" then
logger.warn("BookOrbit: ignoring unreadable provision file", provision_path)
return
end
local server_url = BookOrbitApi.normalizeServerUrl(provision.server_url)
if not server_url or type(provision.username) ~= "string" or type(provision.userkey) ~= "string" then
logger.warn("BookOrbit: ignoring incomplete provision file", provision_path)
return
end
local fingerprint = md5(table.concat({
server_url, provision.username, provision.userkey, tostring(provision.generated_at),
}, "\0"))
if self.settings.provision_fingerprint ~= fingerprint then
self.settings.server_url = server_url
self.settings.username = provision.username
self.settings.userkey = provision.userkey
self.settings.provision_fingerprint = fingerprint
self.provision_applied = true
G_reader_settings:flush()
logger.info("BookOrbit: applied provision file for", provision.username)
UIManager:nextTick(function()
UIManager:show(InfoMessage:new{
text = T(_("BookOrbit sync is set up for %1 as %2."), provision.server_url, provision.username),
timeout = 5,
})
end)
end
os.remove(provision_path)
end
function BookOrbit:apiOpts()
return {
server_url = self.settings.server_url,
username = self.settings.username,
userkey = self.settings.userkey,
device_id = self.device_id,
device_model = Device.model,
plugin_version = PLUGIN_VERSION,
}
end
function BookOrbit:newClient()
return BookOrbitApi.new(self:apiOpts())
end
function BookOrbit:isLoggedIn()
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
end
function BookOrbit:onStart()
if PluginShare.bookorbit_auto_open_done then return end
PluginShare.bookorbit_auto_open_done = true
local mode = self.settings.catalog_auto_open or "off"
if mode == "off" then return end
UIManager:scheduleIn(1.2, function()
self:maybeAutoOpenCatalog(mode)
end)
end
function BookOrbit:maybeAutoOpenCatalog(mode)
if mode ~= "filemanager" and mode ~= "always" then return end
if self.provision_applied then return end
if not self:isLoggedIn() then return end
if self.catalog_browser ~= nil then return end
if mode == "filemanager" and not (self.ui and self.ui.file_chooser ~= nil) then
return
end
self:browseCatalog(true)
end
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
local function promptLogin()
UIManager:show(InfoMessage:new{
text = _("Please configure the BookOrbit server and login first."),
timeout = 3,
})
end
function BookOrbit:onDispatcherRegisterActions()
Dispatcher:registerAction("bookorbit_sync_now",
{ category = "none", event = "BookOrbitSyncNow", title = _("BookOrbit: sync all books"), general = true })
Dispatcher:registerAction("bookorbit_sync_book",
{ category = "none", event = "BookOrbitSyncBook", title = _("BookOrbit: sync this book"), reader = true })
Dispatcher:registerAction("bookorbit_push_progress",
{ category = "none", event = "BookOrbitPushProgress", title = _("BookOrbit: push progress"), reader = true })
Dispatcher:registerAction("bookorbit_pull_progress",
{ category = "none", event = "BookOrbitPullProgress", title = _("BookOrbit: pull progress"), reader = true })
Dispatcher:registerAction("bookorbit_open_dashboard",
{ category = "none", event = "BookOrbitOpenDashboard", title = _("BookOrbit: open dashboard"), general = true, separator = true })
end
function BookOrbit:onReaderReady()
if self.settings.auto_sync then
UIManager:nextTick(function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
return
end
self:getProgress(true, false)
end)
if self.settings.annotation_sync then
UIManager:scheduleIn(2, function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
return
end
self:exchangeAnnotationsForOpenBook()
end)
end
end
self:registerEvents()
self.last_page = self.ui:getCurrentPage()
end
-- Two-way annotation pull/push for the open book. Runs once per book open;
-- safe to call again manually, guarded against concurrent syncs.
function BookOrbit:exchangeAnnotationsForOpenBook()
if self.annotation_exchange_running then return end
if not self:isLoggedIn() or not self.ui or not self.ui.document then return end
if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end
local digest = self:getDocumentDigest()
if not digest then return end
local state = BookOrbitState.open()
if not state:getBook(digest) then
-- Unknown or unmatched book: the close-path snapshot sync matches it.
return
end
self.annotation_exchange_running = true
local ok, result, err = pcall(BookOrbitAnnotations.exchangeOpenBook, {
client = self:newClient(),
state = state,
digest = digest,
ui = self.ui,
})
state:flush()
self.annotation_exchange_running = false
if not ok then
logger.err("BookOrbit: annotation exchange error:", result)
elseif result then
local touched = (result.applied or 0) + (result.deleted or 0)
if touched > 0 then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"), touched))
end
elseif err and err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
end
-- Menu
local function getNameStrategy(strategy)
if strategy == SYNC_STRATEGY.PROMPT then
return _("Prompt")
elseif strategy == SYNC_STRATEGY.SILENT then
return _("Silently")
else
return _("Never")
end
end
function BookOrbit:strategyMenu(getter, setter)
local function item(text, value)
return {
text = text,
checked_func = function() return getter() == value end,
callback = function() setter(value) end,
}
end
return {
item(_("Silently"), SYNC_STRATEGY.SILENT),
item(_("Prompt"), SYNC_STRATEGY.PROMPT),
item(_("Never"), SYNC_STRATEGY.DISABLE),
}
end
function BookOrbit:hasKnownUpdate()
return BookOrbitUpdater.isNewer(self.settings.update_latest_version, PLUGIN_VERSION) == true
end
function BookOrbit:updateCheckMenuText()
if self:hasKnownUpdate() then
return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)
end
if self:isLoggedIn() then
return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)
end
return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)
end
function BookOrbit:catalogAutoOpenLabel()
local mode = self.settings.catalog_auto_open or "off"
if mode == "filemanager" then
return _("File manager only")
elseif mode == "always" then
return _("Every startup")
end
return _("Off")
end
function BookOrbit:catalogAutoOpenMenu()
local function item(text, value)
return {
text = text,
checked_func = function() return (self.settings.catalog_auto_open or "off") == value end,
callback = function()
self.settings.catalog_auto_open = value
G_reader_settings:flush()
end,
}
end
return {
item(_("Off"), "off"),
item(_("File manager only"), "filemanager"),
item(_("Every startup"), "always"),
}
end
function BookOrbit:addToMainMenu(menu_items)
menu_items.bookorbit = {
text = _("BookOrbit"),
-- Fallback placement only: BookOrbitMenuPin normally pins this entry
-- right below calibre on the first page of the Tools menu.
sorting_hint = "tools",
sub_item_table = {
{
text = _("Open dashboard"),
enabled_func = function()
return self:isLoggedIn()
end,
callback = function()
self:browseCatalog()
end,
},
{
text_func = function()
return self:updateCheckMenuText()
end,
enabled_func = function()
return self:isLoggedIn()
and not BookOrbitSweep.isRunning()
and not BookOrbitBookSync.isRunning()
end,
keep_menu_open = true,
callback = function()
self:checkForUpdate()
end,
},
{
text_func = function()
local status = BookOrbitSweep.syncStatus()
local when = (status.lastSweepAt == 0) and _("never")
or os.date("%Y-%m-%d %H:%M", status.lastSweepAt)
if status.unmatched > 0 then
return T(_("Last sync: %1 (%2 linked, %3 unmatched)"), when, status.matched, status.unmatched)
elseif status.matched > 0 then
return T(_("Last sync: %1 (%2 linked)"), when, status.matched)
end
return T(_("Last sync: %1"), when)
end,
enabled = false,
separator = true,
},
{
text = _("Sync this book now"),
enabled_func = function()
return self:isLoggedIn() and self.ui.document ~= nil
and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
callback = function()
self:onBookOrbitSyncBook()
end,
},
{
text = _("Sync all books now"),
enabled_func = function()
return self:isLoggedIn() and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
callback = function()
self:startSweep()
end,
separator = true,
},
{
text = _("Auto sync this book"),
checked_func = function() return self.settings.auto_sync end,
help_text = _([[Pulls progress when a book is opened; pushes progress, highlights, status, rating and reading time when it is closed and on suspend.]]),
callback = function()
self:onBookOrbitToggleAutoSync(nil, true)
end,
},
{
text = _("Two-way highlight sync"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
},
{
text = _("Skip auto-sync when offline"),
checked_func = function() return self.settings.skip_sync_when_offline end,
enabled_func = function() return self.settings.auto_sync end,
help_text = _([[When enabled, automatic sync on book open is skipped if the device is not already online. Prevents the e-reader from stalling while trying to connect to Wi-Fi.]]),
callback = function()
self.settings.skip_sync_when_offline = not self.settings.skip_sync_when_offline
end,
separator = true,
},
{
text = _("Sync settings"),
sub_item_table = {
{
text_func = function()
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
{
text_func = function()
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
end,
enabled_func = function() return self.settings.auto_sync end,
keep_menu_open = true,
callback = function(touchmenu_instance)
local SpinWidget = require("ui/widget/spinwidget")
local items = SpinWidget:new{
text = _([[This value determines how many page turns it takes to push book progress.
If set to 0, updating progress based on page turns will be disabled.]]),
value = self.settings.pages_before_update or 0,
value_min = 0,
value_max = 999,
value_step = 1,
value_hold_step = 10,
ok_text = _("Set"),
title_text = _("Number of pages before update"),
default_value = 10,
callback = function(spin)
self:setPagesBeforeUpdate(spin.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(items)
end,
},
{
text_func = function()
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_forward end,
function(value) self.settings.sync_forward = value end
),
},
{
text_func = function()
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_backward end,
function(value) self.settings.sync_backward = value end
),
},
},
},
{
text = _("Account & setup"),
sub_item_table = {
{
text = _("BookOrbit server address"),
keep_menu_open = true,
callback = function()
self:setServerAddress()
end,
},
{
text_func = function()
return self.settings.userkey and _("Logout") or _("Login")
end,
enabled_func = function()
return self.settings.server_url ~= nil
end,
keep_menu_open = true,
callback_func = function()
if self.settings.userkey then
return function(menu)
self:logout(menu)
end
else
return function(menu)
self:login(menu)
end
end
end,
},
},
},
},
}
end
function BookOrbit:dashboardMenuItems(catalog)
local menu_items = {}
self:addToMainMenu(menu_items)
local bookorbit_items = menu_items.bookorbit.sub_item_table or {}
local items = {
icon = "appbar.menu",
}
for index, item in ipairs(bookorbit_items) do
if index ~= 1 then
table.insert(items, item)
end
end
if catalog then
table.insert(items, {
text = _("Close BookOrbit"),
separator = true,
callback = function()
local menu_container = self.dashboard_menu_container
self.dashboard_menu_container = nil
if menu_container then UIManager:close(menu_container) end
catalog:onCloseAllMenus()
end,
})
end
return items
end
function BookOrbit:showDashboardMenu(catalog)
local CenterContainer = require("ui/widget/container/centercontainer")
local TouchMenu = require("ui/widget/touchmenu")
if self.dashboard_menu_container then
local old_container = self.dashboard_menu_container
self.dashboard_menu_container = nil
UIManager:close(old_container)
end
local menu_container = CenterContainer:new{
covers_header = true,
ignore = "height",
dimen = Device.screen:getSize(),
}
local dashboard_menu = TouchMenu:new{
width = Device.screen:getWidth(),
last_index = 1,
tab_item_table = { self:dashboardMenuItems(catalog) },
show_parent = menu_container,
}
local closing = false
dashboard_menu.close_callback = function()
if closing then return true end
closing = true
if self.dashboard_menu_container == menu_container then
self.dashboard_menu_container = nil
end
UIManager:close(menu_container)
return true
end
menu_container[1] = dashboard_menu
self.dashboard_menu_container = menu_container
UIManager:show(menu_container)
end
function BookOrbit:openCatalogBrowser(prefer_cached_dashboard)
if self.catalog_browser ~= nil then return end
self.catalog_browser = BookOrbitCatalog:new{
title = _("BookOrbit"),
api = self:apiOpts(),
settings = self.settings,
path = self.path,
prefer_cached_dashboard = prefer_cached_dashboard,
save_settings = function()
G_reader_settings:flush()
end,
show_dashboard_menu = function(catalog)
self:showDashboardMenu(catalog)
end,
_manager = self,
close_callback = function()
UIManager:close(self.catalog_browser)
self.catalog_browser = nil
end,
}
UIManager:show(self.catalog_browser)
end
function BookOrbit:browseCatalog(allow_offline)
if not self:isLoggedIn() then
promptLogin()
return
end
if allow_offline then
self:openCatalogBrowser(true)
else
NetworkMgr:runWhenConnected(function()
self:openCatalogBrowser(false)
end)
end
end
function BookOrbit:setServerAddress()
local dialog
dialog = InputDialog:new{
title = _("BookOrbit server address"),
input = self.settings.server_url or "https://",
input_hint = "https://bookorbit.example.com",
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(dialog)
end,
},
{
text = _("OK"),
is_enter_default = true,
callback = function()
local normalized = BookOrbitApi.normalizeServerUrl(dialog:getInputText())
self.settings.server_url = normalized
UIManager:close(dialog)
if normalized then
Notification:notify(T(_("BookOrbit server set to %1"), normalized))
end
end,
},
},
},
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function BookOrbit:login(menu)
if NetworkMgr:willRerunWhenOnline(function() self:login(menu) end) then
return
end
local dialog
dialog = MultiInputDialog:new{
title = self.title,
fields = {
{
text = self.settings.username,
hint = "username",
},
{
hint = "password",
text_type = "password",
},
},
description = _("Credentials are created in BookOrbit web settings under Settings, KOReader."),
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(dialog)
end,
},
{
text = _("Login"),
is_enter_default = true,
callback = function()
local username, password = unpack(dialog:getFields())
username = util.trim(username or "")
if username == "" or not password or password == "" then
UIManager:show(InfoMessage:new{ text = _("Please enter a username and password."), timeout = 2 })
return
end
UIManager:close(dialog)
UIManager:scheduleIn(0.5, function()
self:doLogin(username, password, menu)
end)
UIManager:show(InfoMessage:new{ text = _("Logging in. Please wait."), timeout = 1 })
end,
},
},
},
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function BookOrbit:doLogin(username, password, menu)
Device:setIgnoreInput(true)
local userkey = md5(password)
local client = BookOrbitApi.new{
server_url = self.settings.server_url,
username = username,
userkey = userkey,
device_id = self.device_id,
device_model = Device.model,
plugin_version = PLUGIN_VERSION,
}
local body, err = client:auth()
Device:setIgnoreInput(false)
if body then
self.settings.username = username
self.settings.userkey = userkey
if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
UIManager:scheduleIn(1, function()
self:maybeCheckForUpdate(false)
end)
elseif err == 401 or err == 403 then
UIManager:show(InfoMessage:new{
text = _("Login failed. Create or check your KOReader credentials in BookOrbit web settings."),
})
else
UIManager:show(InfoMessage:new{ text = T(_("Could not reach the BookOrbit server: %1"), tostring(err)) })
end
end
function BookOrbit:logout(menu)
self.settings.userkey = nil
if menu then menu:updateItems() end
end
function BookOrbit: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 BookOrbit:setPagesBeforeUpdate(value)
self.settings.pages_before_update = value
end
function BookOrbit: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
-- Live progress sync (kosync mirror)
function BookOrbit: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 BookOrbit: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 BookOrbit:getDocumentDigest()
local digest = self.ui.doc_settings:readSetting("partial_md5_checksum")
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
self.ui.doc_settings:saveSetting("partial_md5_checksum", computed)
return computed
end
function BookOrbit:syncToProgress(progress)
logger.dbg("BookOrbit: syncing to progress", progress)
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
local function hasUsableRemoteProgress(progress)
return progress ~= nil and progress ~= ""
end
function BookOrbit: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 BookOrbit:applyRemoteProgress(body, on_done)
self:syncToProgress(body.progress)
if on_done then
UIManager:scheduleIn(0.1, function()
on_done(false)
end)
end
end
function BookOrbit:reconcileProgressBeforeBookSync(digest, on_done)
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
return
end
if self:getDocumentDigest() ~= digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return
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
end
if not body.percentage then
on_done(false)
return
end
if body.device == Device.model and body.device_id == self.device_id then
on_done(false)
return
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
end
local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
if not hasUsableRemoteProgress(body.progress) then
if remote_newer then
UIManager:show(InfoMessage:new{
text = _("BookOrbit has newer progress but no exact location. Syncing book data without changing progress."),
timeout = 4,
})
on_done(true)
else
on_done(false)
end
return
end
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)
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,
})
elseif remote_newer then
UIManager:show(InfoMessage:new{
text = _("BookOrbit has newer progress. Syncing book data without changing progress."),
timeout = 4,
})
on_done(true)
else
on_done(false)
end
end
function BookOrbit:updateProgress(ensure_networking, interactive, on_suspend)
if not self:isLoggedIn() then
if interactive then promptLogin() end
return
end
if not self.ui or not self.ui.document then 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:willRerunWhenOnline(function() self:updateProgress(ensure_networking, interactive, on_suspend) 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
UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 })
if not on_suspend then self:maybeCheckForUpdate(false) end
else
showSyncError()
end
elseif not body then
logger.dbg("BookOrbit: push failed:", err)
elseif not on_suspend then
self:maybeCheckForUpdate(false)
end
if on_suspend and Device:hasWifiManager() then
NetworkMgr:disableWifi()
end
self.push_timestamp = now
end
function BookOrbit:getProgress(ensure_networking, interactive)
if not self:isLoggedIn() then
if interactive then promptLogin() end
return
end
if not self.ui or not self.ui.document then 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:willRerunWhenOnline(function() self:getProgress(ensure_networking, interactive) 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
logger.dbg("BookOrbit: pull failed:", err)
return
end
self:maybeCheckForUpdate(false)
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)
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)
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)
end,
})
end
end
-- Manual sync triggers
function BookOrbit:startSweep()
if not self:isLoggedIn() then
promptLogin()
return
end
if BookOrbitBookSync.isRunning() then
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
return
end
local sweep_opts = {
api = self:apiOpts(),
interactive = true,
annotation_sync = self.settings.annotation_sync,
on_finish = function(err)
if not err then self:maybeCheckForUpdate(false) end
end,
}
if NetworkMgr:willRerunWhenOnline(function() BookOrbitSweep.run(sweep_opts) end) then
return
end
BookOrbitSweep.run(sweep_opts)
end
function BookOrbit:onBookOrbitSyncBook()
if not self:isLoggedIn() then
promptLogin()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
return
end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
return
end
local snap = BookOrbitBookSync.capture(self)
if not snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
return
end
local api_opts = self:apiOpts()
local run_book_sync = function(skip_progress)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
return
end
if latest_snap.digest ~= snap.digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return
end
BookOrbitBookSync.run{ api = api_opts, snap = latest_snap, reason = "manual", interactive = true, plugin = self,
annotation_sync = self.settings.annotation_sync, skip_progress = skip_progress }
end
local run = function()
self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end
-- Events
function BookOrbit:_onCloseDocument()
logger.dbg("BookOrbit: onCloseDocument")
self.onResume = nil
self.onSuspend = nil
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
logger.dbg("BookOrbit: close sync skipped, another sync is running")
return
end
-- Snapshot now: reader objects die after this handler returns. ReaderUI
-- already flushed the sidecar and statistics before broadcasting
-- CloseDocument, so memory, sidecar and stats DB agree at this point.
local snap = BookOrbitBookSync.capture(self)
if not snap then return end
local api_opts = self:apiOpts()
NetworkMgr:goOnlineToRun(function()
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "close", interactive = false,
annotation_sync = self.settings.annotation_sync }
end)
end
function BookOrbit:_onPageUpdate(page)
if page == nil then return end
if self.last_page ~= page then
self.last_page = page
self.last_page_turn_timestamp = os.time()
self.page_update_counter = self.page_update_counter + 1
-- A pending periodic push is re-delayed on every page turn so it
-- only fires once the reader is actually idle.
if self.settings.auto_sync and (self.periodic_push_scheduled
or (self.settings.pages_before_update or 0) > 0 and self.page_update_counter >= self.settings.pages_before_update) then
self:schedulePeriodicPush()
end
end
end
function BookOrbit:_onResume()
logger.dbg("BookOrbit: onResume")
if Device:hasWifiRestore() and NetworkMgr.wifi_was_on and G_reader_settings:isTrue("auto_restore_wifi") then
return
end
UIManager:scheduleIn(1, function()
self:getProgress(true, false)
end)
end
function BookOrbit:_onSuspend()
logger.dbg("BookOrbit: onSuspend")
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
if not self:isLoggedIn() then return end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end
local snap = BookOrbitBookSync.capture(self)
if not snap then return end
local on_finish
if Device:hasWifiManager() then
on_finish = function() NetworkMgr:disableWifi() end
end
local api_opts = self:apiOpts()
-- Synchronous: the device is about to sleep, so the uploads must not be
-- parked on the scheduler (they would run after resume and then kill
-- wifi mid-read via on_finish).
local run = function()
BookOrbitBookSync.run{
api = api_opts, snap = snap, reason = "suspend",
interactive = false, synchronous = true, plugin = self, on_finish = on_finish,
annotation_sync = self.settings.annotation_sync,
}
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end
function BookOrbit:_onNetworkConnected()
logger.dbg("BookOrbit: onNetworkConnected")
UIManager:scheduleIn(0.5, function()
self:getProgress(false, false)
self:maybeCheckForUpdate(false)
end)
end
function BookOrbit:_onNetworkDisconnecting()
logger.dbg("BookOrbit: onNetworkDisconnecting")
self:updateProgress(false, false)
end
function BookOrbit:onBookOrbitPushProgress()
self:updateProgress(true, true)
end
function BookOrbit:onBookOrbitPullProgress()
self:getProgress(true, true)
end
function BookOrbit:onBookOrbitSyncNow()
self:startSweep()
end
function BookOrbit:onBookOrbitOpenDashboard()
self:browseCatalog(false)
end
function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
if toggle == self.settings.auto_sync then
return true
end
if not self.settings.auto_sync
and Device:hasSeamlessWifiToggle()
and G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
UIManager:show(InfoMessage:new{
text = _("Auto sync needs KOReader to turn Wi-Fi on without asking. Open Network settings, set 'Action when Wi-Fi is off' to 'Turn on', then enable Auto sync again."),
})
return true
end
self.settings.auto_sync = not self.settings.auto_sync
self:registerEvents()
if self.settings.auto_sync then
self:getProgress(true, true)
else
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
self.page_update_counter = 0
if from_menu then
self:updateProgress(true, true)
end
end
if not from_menu then
Notification:notify(self.settings.auto_sync and _("BookOrbit auto progress sync: on") or _("BookOrbit auto progress sync: off"))
end
return true
end
function BookOrbit:registerEvents()
self.onPageUpdate = self._onPageUpdate
if self.settings.auto_sync then
self.onCloseDocument = self._onCloseDocument
self.onResume = self._onResume
self.onSuspend = self._onSuspend
self.onNetworkConnected = self._onNetworkConnected
self.onNetworkDisconnecting = self._onNetworkDisconnecting
else
self.onCloseDocument = nil
self.onResume = nil
self.onSuspend = nil
self.onNetworkConnected = nil
self.onNetworkDisconnecting = nil
end
end
function BookOrbit:onCloseWidget()
if self.periodic_push_task then
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_task = nil
end
end
function BookOrbit:checkForUpdate()
if not self:isLoggedIn() then
promptLogin()
return
end
NetworkMgr:runWhenConnected(function()
self:doCheckForUpdate()
end)
end
function BookOrbit:maybeCheckForUpdate(interactive)
if not self:isLoggedIn() or self._checking_update or self._updating then return end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end
local now = os.time()
if not interactive and now - (self.settings.update_check_last_at or 0) < UPDATE_CHECK_INTERVAL then
return
end
self._checking_update = true
local body, err = self:newClient():getPluginVersion()
self._checking_update = false
if not body then
if interactive then
UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
timeout = 4,
})
else
logger.dbg("BookOrbit: plugin update check failed:", err)
end
return
end
self:handleUpdateVersionResponse(body, interactive, interactive or self.catalog_browser == nil)
end
function BookOrbit:doCheckForUpdate()
local checking = InfoMessage:new{ text = _("Checking for update...") }
UIManager:show(checking)
local body, err = self:newClient():getPluginVersion()
UIManager:close(checking)
if not body then
UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
timeout = 4,
})
return
end
self:handleUpdateVersionResponse(body, true, true)
end
function BookOrbit:handleUpdateVersionResponse(body, interactive, prompt_allowed)
local server_ver = body.serverVersion or "unknown"
local plugin_latest = body.pluginVersion
self.settings.update_check_last_at = os.time()
if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
G_reader_settings:flush()
if interactive then
UIManager:show(InfoMessage:new{
text = _("Could not determine the latest plugin version from the server."),
timeout = 4,
})
end
return
end
self.settings.update_latest_version = plugin_latest
G_reader_settings:flush()
if BookOrbitUpdater.isNewer(plugin_latest, PLUGIN_VERSION) ~= true then
if interactive then
UIManager:show(InfoMessage:new{
text = T(_("Plugin is up to date (v%1).\nServer: v%2"), PLUGIN_VERSION, server_ver),
timeout = 4,
})
end
return
end
if not prompt_allowed then
return
end
if not interactive and self.settings.update_dismissed_version == plugin_latest then
return
end
if not interactive then
self.settings.update_dismissed_version = plugin_latest
G_reader_settings:flush()
end
UIManager:show(ConfirmBox:new{
text = T(_("Update available: v%1 -> v%2\nServer: v%3\n\nDownload and apply the update now?"),
PLUGIN_VERSION, plugin_latest, server_ver),
ok_text = _("Update"),
ok_callback = function()
self:applyUpdate(plugin_latest)
end,
})
end
function BookOrbit:applyUpdate(new_version)
if self._updating then
UIManager:show(InfoMessage:new{ text = _("An update is already in progress."), timeout = 3 })
return
end
self._updating = true
self:_doApplyUpdate(new_version)
self._updating = false
end
function BookOrbit:_doApplyUpdate(new_version)
if not self.path then
UIManager:show(InfoMessage:new{
text = _("Cannot determine plugin path. Update aborted."),
timeout = 3,
})
return
end
local progress = InfoMessage:new{ text = T(_("Downloading BookOrbit v%1..."), new_version) }
UIManager:show(progress)
UIManager:forceRePaint()
local ok, err = BookOrbitUpdater.apply(self:newClient(), self.path)
UIManager:close(progress)
if not ok then
local msg
if type(err) == "number" and err == 503 then
msg = _("Update failed: the server does not have the plugin package available.")
else
msg = T(_("Update failed: %1"), tostring(err or "unknown error"))
end
UIManager:show(InfoMessage:new{ text = msg, timeout = 6 })
return
end
UIManager:show(ConfirmBox:new{
text = T(_("BookOrbit v%1 installed. KOReader needs to restart to apply the update."), new_version),
ok_text = _("Restart now"),
ok_callback = function()
-- Exit code 85 triggers an app restart on Kobo and most e-ink platforms.
-- On other platforms KOReader exits cleanly; reopen it to apply the update.
UIManager:quit(UIManager.RETURN_CODE_REBOOT or 85)
end,
cancel_text = _("Later"),
})
end
return BookOrbit