Files
bookorbit/koreader-plugin/bookorbit.koplugin/main.lua
T
neon c28ea29671 feat(sync): three way (kobo, koreader and bookorbit) progress and annotation sync (#338)
Introduces a custom KOReader plugin to synchronize reading progress, stats, and annotations. Adds backend controllers, services, and database schema updates to ingest plugin data. Updates the frontend to display KOReader annotations alongside built-in reader highlights.

Closes #337
2026-06-25 20:13:04 -06:00

1215 lines
42 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 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 = "0.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,
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_recent_searches = {},
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.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
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)
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
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
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, separator = true })
end
function BookOrbit:onReaderReady()
if self.settings.auto_sync then
UIManager:nextTick(function()
self:getProgress(true, false)
end)
if self.settings.annotation_sync then
UIManager:scheduleIn(2, function()
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: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 = _("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,
separator = true,
},
{
text = _("Browse library"),
enabled_func = function()
return self:isLoggedIn()
end,
callback = function()
self:browseCatalog()
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_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 = _("Sync behavior"),
sub_item_table = {
{
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 = _("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_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,
separator = true,
},
{
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,
},
},
}
end
function BookOrbit:browseCatalog()
if not self:isLoggedIn() then
promptLogin()
return
end
NetworkMgr:runWhenConnected(function()
self.catalog_browser = BookOrbitCatalog:new{
title = _("BookOrbit"),
api = self:apiOpts(),
settings = self.settings,
save_settings = function()
G_reader_settings:flush()
end,
_manager = self,
close_callback = function()
UIManager:close(self.catalog_browser)
self.catalog_browser = nil
end,
}
UIManager:show(self.catalog_browser)
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
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 = function()
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "manual", interactive = true, plugin = self,
annotation_sync = self.settings.annotation_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.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: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 = _("You will have to switch the 'Action when Wi-Fi is off' Network setting to 'turn on' to be able to enable this feature!"),
})
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()
if self.settings.auto_sync then
self.onCloseDocument = self._onCloseDocument
self.onPageUpdate = self._onPageUpdate
self.onResume = self._onResume
self.onSuspend = self._onSuspend
self.onNetworkConnected = self._onNetworkConnected
self.onNetworkDisconnecting = self._onNetworkDisconnecting
else
self.onCloseDocument = nil
self.onPageUpdate = 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