Files
bookorbit/koreader-plugin/bookorbit.koplugin/main.lua
T
neon e28f45452d fix(koreader): keep each device's layout at every interface size
Layout breakpoints use the screen width at 1x, so Interface size scales text,
icons and spacing without dropping Home columns or detail panes. Every page,
sheet and widget picks a layout that fits instead of cutting text or leaving
gaps; search becomes balanced list-row sections, Edit Home keeps hidden widgets,
stock KOReader messages become BookOrbit toasts, and the plugin is 2.1.0.
2026-10-24 03:46:05 +00:00

1495 lines
56 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.
This file owns the plugin lifecycle and reader events. The rest of the
controller is split across mixins installed below: bookorbit_main_menu
(Tools/dashboard menu and account dialogs), bookorbit_progress_sync (the
kosync mirror) and bookorbit_updater (self-update checks and apply).
]]
local Device = require("device")
local Dispatcher = require("dispatcher")
local NetworkMgr = require("ui/network/manager")
local Toast = setmetatable({}, { __index = function(_t, key) return require("bookorbit_toast")[key] end })
local PluginShare = require("pluginshare")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local md5 = require("ffi/sha2").md5
local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitActivity = require("bookorbit_activity")
local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitBookmarks = require("bookorbit_bookmarks")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitCatalog = require("bookorbit_catalog")
local BookOrbitChapterMap = require("bookorbit_chapter_map")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitLifecycleOutbox = require("bookorbit_lifecycle_outbox")
local BookOrbitOpenAnnotationScheduler = require("bookorbit_open_annotation_scheduler")
local BookOrbitState = require("bookorbit_state")
local BookOrbitStateManager = require("bookorbit_state_manager")
local BookOrbitStatsReader = require("bookorbit_stats_reader")
local BookOrbitSyncCoordinator = require("bookorbit_sync_coordinator")
local BookOrbitSyncJobRunner = require("bookorbit_sync_job_runner")
local BookOrbitMainMenu = require("bookorbit_main_menu")
local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitNoteStamp = require("bookorbit_note_stamp")
local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "2.1.0"
local SYNC_STRATEGY = {
PROMPT = 1,
SILENT = 2,
DISABLE = 3,
}
local LAST_ERROR_LABELS = {
auth = _("login failed"),
network = _("server not reachable"),
server = _("server request failed"),
invalid_json = _("invalid server response"),
unsupported_server = _("server update required"),
body_too_large = _("request too large"),
background_request_interrupted = _("background request interrupted"),
partial_failure = _("partial sync failure"),
outbox_persistence = _("lifecycle sync could not be saved"),
}
local SYNC_JOB_PRIORITY = BookOrbitSyncCoordinator.PRIORITY
local OPEN_ANNOTATION_SYNC_DELAY = 2
local OPEN_HIGHLIGHT_RETRY_DELAY = 8
local OPEN_HIGHLIGHT_MAX_RETRIES = 2
local BookOrbit = WidgetContainer:extend{
name = "bookorbit",
title = _("Login to BookOrbit"),
push_timestamp = nil,
pull_timestamp = nil,
last_page = 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,
}
-- The version literal is parsed out of main.lua by the BookOrbit server's
-- plugin package endpoint; the mixin modules read it via the class field.
BookOrbit.PLUGIN_VERSION = PLUGIN_VERSION
BookOrbit.SYNC_STRATEGY = SYNC_STRATEGY
BookOrbitMainMenu.install(BookOrbit)
BookOrbitProgressSync.install(BookOrbit)
BookOrbitUpdater.install(BookOrbit)
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 = 3,
sync_forward = SYNC_STRATEGY.PROMPT,
sync_backward = SYNC_STRATEGY.DISABLE,
catalog_view_mode = "mosaic",
catalog_sort = "recently_added",
catalog_grid_cols = 4,
catalog_grid_rows = 3,
catalog_mosaic_show_titles = false,
catalog_recent_searches = {},
catalog_auto_open = "off",
menu_bar_icon = true,
ui_scale = 1.05,
catalog_dashboard_cache = nil,
catalog_detail_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:requestProgressPush(false, false, "periodic")
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 = self.default_settings.pages_before_update
end
if self.settings.annotation_sync == nil then
self.settings.annotation_sync = true
end
if self.settings.sync_forward == nil then
self.settings.sync_forward = self.default_settings.sync_forward
end
if self.settings.sync_backward == nil then
self.settings.sync_backward = self.default_settings.sync_backward
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.settings.ui_scale = require("bookorbit_board_metrics").setUserScale(self.settings.ui_scale)
self:applyProvision()
BookOrbitStateManager.setAccount(self.settings)
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.settings.menu_bar_icon ~= false)
-- The reader clears its document before its widgets close, so remember
-- which side this instance serves. A book opening clears the way back to
-- BookOrbit a closed one left.
self.in_reader = self.ui ~= nil and self.ui.document ~= nil
if self.in_reader then PluginShare.bookorbit_return_home = nil else self:registerEvents() end
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
self:onStart()
UIManager:scheduleIn(5, function()
self:requestLifecycleOutboxDrain("startup")
self:requestUpdateCheck(false, "startup")
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()
Toast.show(T(_("BookOrbit is set up as %1"), provision.username), { timeout = 5 })
end)
end
os.remove(provision_path)
end
function BookOrbit:apiOpts(background_requests)
BookOrbitStateManager.setAccount(self.settings)
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,
background_requests = background_requests == true,
}
end
function BookOrbit:newClient()
return BookOrbitApi.new(self:apiOpts(true))
end
function BookOrbit:runInSyncCoroutine(fn)
if Trapper:isWrapped() then
return fn()
end
return Trapper:wrap(fn)
end
function BookOrbit:isLoggedIn()
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
end
function BookOrbit:errorLabel(err, fallback)
if LAST_ERROR_LABELS[err] then return LAST_ERROR_LABELS[err] end
if err == 401 or err == 403 then return LAST_ERROR_LABELS.auth end
if type(err) == "number" then
return T(_("server error %1"), err)
end
return fallback or tostring(err or _("unknown error"))
end
local BOOK_EVENTS = { book_sync = true, progress_push = true, progress_pull = true }
function BookOrbit:recordSyncSuccess(event, message)
if event ~= "connection_test" then
self.settings.last_sync = {
event = event,
at = os.time(),
message = message,
}
self.settings.last_error = nil
elseif self.settings.last_error and self.settings.last_error.event == "connection_test" then
self.settings.last_error = nil
end
BookOrbitActivity.recordSuccess(self.settings, event, message)
-- When the open book last reached BookOrbit, for its This book sheet.
if BOOK_EVENTS[event] and self.ui and self.ui.document then
local digest = self:getDocumentDigest()
if digest then
self.book_sync_marks = self.book_sync_marks or {}
self.book_sync_marks[digest] = os.time()
end
end
G_reader_settings:flush()
end
function BookOrbit:recordSyncError(event, err, message)
local error_message = message or self:errorLabel(err)
if event ~= "connection_test" or not self.settings.last_error or self.settings.last_error.event == "connection_test" then
self.settings.last_error = {
event = event,
at = os.time(),
code = err,
message = error_message,
}
end
BookOrbitActivity.recordError(self.settings, event, error_message)
G_reader_settings:flush()
end
function BookOrbit:recordHighlightSync(summary, err)
summary = BookOrbitHighlightSummary.normalize(summary)
summary.message = summary.message or BookOrbitHighlightSummary.message(summary)
self.settings.last_highlight_sync = summary
BookOrbitActivity.recordHighlights(self.settings, summary)
local code = BookOrbitHighlightSummary.actionableError(summary, err)
if code then
local message
if code == "unsupported_server" then
message = _("BookOrbit server needs an update for two-way highlights.")
end
self:recordSyncError("highlight_sync", code, message)
else
G_reader_settings:flush()
end
end
function BookOrbit:shouldSkipAutoSyncOffline(event)
if self.settings.skip_sync_when_offline and not NetworkMgr:isConnected() then
logger.dbg("BookOrbit: automatic sync skipped while offline:", event)
return true
end
return false
end
function BookOrbit:getSyncCoordinator()
if not self.sync_coordinator then
self.sync_coordinator = BookOrbitSyncCoordinator.new{
on_idle = function()
UIManager:nextTick(function()
self:runPendingUpdateCheck()
end)
end,
}
end
return self.sync_coordinator
end
function BookOrbit:getLifecycleOutbox()
if not self.lifecycle_outbox then
self.lifecycle_outbox = BookOrbitLifecycleOutbox.open()
end
return self.lifecycle_outbox
end
function BookOrbit:getLifecycleOutboxStatus()
return self:getLifecycleOutbox():diagnostics()
end
function BookOrbit:recordLifecycleOutboxError(err)
local message = self:errorLabel("outbox_persistence")
if err == "hard_limit" then
message = _("Lifecycle sync storage is full. Open BookOrbit diagnostics and reconnect before closing more books.")
end
self:recordSyncError("lifecycle_outbox", "outbox_persistence", message)
require("bookorbit_dialog_ui").notice{ title = _("BookOrbit could not save a sync"), text = message }
end
function BookOrbit:enqueueLifecycleSnapshot(snap, reason)
local entry, status_or_err = self:getLifecycleOutbox():enqueue(snap, {
reason = reason,
annotation_sync = self.settings.annotation_sync,
})
if not entry then
self:recordLifecycleOutboxError(status_or_err)
return nil
end
if status_or_err and status_or_err.soft_limit then
local message = _("BookOrbit has many pending lifecycle syncs. Reconnect soon to drain them.")
self:recordSyncError("lifecycle_outbox", "outbox_persistence", message)
Toast.error(_("Many syncs are waiting. Reconnect soon"))
end
return entry
end
-- `interactive` drains may bring Wi-Fi up (and prompt); lifecycle and
-- background drains only run when a connection already exists, so closing or
-- suspending never triggers a connectivity prompt or leaves the radio on.
function BookOrbit:requestLifecycleOutboxDrain(source, interactive)
if not self:isLoggedIn() then return false end
local submit = function()
UIManager:nextTick(function()
if not NetworkMgr:isConnected() then return end
local outbox = self:getLifecycleOutbox()
local entry = outbox:nextEntry{ include_blocked = interactive == true }
if not entry then
self:scheduleLifecycleOutboxRetry()
return
end
self:submitSyncJob{
family = "lifecycle_outbox",
label = _("Lifecycle sync"),
source = source or "recovery",
priority = SYNC_JOB_PRIORITY.lifecycle,
interactive = false,
async = true,
run = function(done)
local current = outbox:readLatest(entry.id)
or outbox:nextEntry{ include_blocked = interactive == true }
if not current then
done()
return
end
local started, start_err = outbox:markStarted(current.id, current.generation)
if not started then
self:recordLifecycleOutboxError(start_err)
done()
return
end
local generation = started.generation
local function update(method, ...)
local updated, err = outbox[method](outbox, started.id, generation, ...)
if not updated then
self:recordLifecycleOutboxError(err)
return false
end
generation = updated.generation
return true
end
local snap = started.snapshot
snap.stats_ids = snap.stats_ids or {}
if (not snap.file or lfs.attributes(snap.file, "mode") ~= "file") then
local state_book = BookOrbitStateManager.getBook(snap.digest)
if state_book and state_book.file
and lfs.attributes(state_book.file, "mode") == "file" then
snap.file = state_book.file
end
end
local finished = false
local function finishEntry(err)
if finished then return end
finished = true
local latest = outbox:readLatest(started.id)
local unmatched = err == "unmatched"
local complete = unmatched or outbox:isComplete(latest)
if complete then
outbox:removeEntry(started.id)
if unmatched then
local last_error = self.settings.last_error
if last_error and last_error.event == "lifecycle_outbox"
and last_error.code == "outbox_blocked" then
self.settings.last_error = nil
end
BookOrbitActivity.recordSuccess(
self.settings, "lifecycle_unmatched", snap.title)
G_reader_settings:flush()
end
else
outbox:markFinished(started.id)
self:scheduleLifecycleOutboxRetry()
if latest and latest.remote_pending then
self:recordSyncError(
"lifecycle_outbox",
"remote_apply_pending",
_("Remote book changes are pending until the local book is available."))
end
if outbox:isBlocked(latest) then
self:recordSyncError(
"lifecycle_outbox",
"outbox_blocked",
_("A pending lifecycle sync keeps failing and was parked. Use \"Sync this book\" to retry it."))
end
end
done()
if complete and (not err or unmatched) then
self:requestLifecycleOutboxDrain("recovery")
end
end
local started_sync = BookOrbitBookSync.run{
api = self:apiOpts(true),
snap = snap,
reason = "recovery",
-- The captured origin decides whether the sidecar may
-- be marked clean; collapsing it to "recovery" left
-- every lifecycle-synced book looking changed to the
-- next sweep forever.
origin = started.reason,
interactive = false,
plugin = self,
annotation_sync = started.annotation_sync,
acknowledged = started.acknowledged,
remote_pending = started.remote_pending,
on_phase_ack = function(phase)
return update("acknowledge", phase)
end,
on_remote_pending = function(kind, payload)
return update("recordRemotePending", kind, payload)
end,
on_remote_applied = function(kind)
return update("clearRemotePending", kind)
end,
on_finish = finishEntry,
}
if started_sync == false then finishEntry("not_started") end
end,
}
end)
end
if NetworkMgr:isConnected() then
submit()
return true
end
if not interactive or self.settings.skip_sync_when_offline then return false end
NetworkMgr:willRerunWhenConnected(submit)
return false
end
-- Nothing else wakes an entry whose backoff runs out: without this it waits for
-- the next book open, close, suspend, network event or manual sync, so a quick
-- relaunch after a cut-off sync left its changes unsent.
function BookOrbit:scheduleLifecycleOutboxRetry()
local outbox = self:getLifecycleOutbox()
local at = outbox:nextRetryAt()
if not at then return end
if self.lifecycle_retry_task then UIManager:unschedule(self.lifecycle_retry_task) end
self.lifecycle_retry_task = function()
self.lifecycle_retry_task = nil
self:requestLifecycleOutboxDrain("retry")
end
UIManager:scheduleIn(math.max(1, at - outbox.now()), self.lifecycle_retry_task)
end
function BookOrbit:getSyncCoordinatorStatus()
if not self.sync_coordinator then
return { pending_count = 0 }
end
return self.sync_coordinator:status()
end
function BookOrbit:getOpenAnnotationScheduler()
if not self.open_annotation_scheduler then
self.open_annotation_scheduler = BookOrbitOpenAnnotationScheduler.new{
delay = OPEN_ANNOTATION_SYNC_DELAY,
schedule_in = function(delay, callback)
UIManager:scheduleIn(delay, callback)
end,
}
end
return self.open_annotation_scheduler
end
function BookOrbit:isOpenBookMatched(digest)
if not digest then return false end
return BookOrbitStateManager.getBook(digest) ~= nil
end
function BookOrbit:recordOpenAnnotationUnmatched(reason)
self:recordHighlightSync({
event = "open_book",
reason = reason or "annotation_open",
skipped = 1,
}, "unmatched")
end
function BookOrbit:scheduleOpenAnnotationSync(digest, reason, opts)
return self:getOpenAnnotationScheduler():schedule(self, digest, reason or "annotation_open", opts)
end
function BookOrbit:retryOpenHighlightSync()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
require("bookorbit_toast").error(_("No book is open"))
return
end
self.open_highlight_retry_status = nil
self:requestAnnotationExchange("diagnostics_retry")
require("bookorbit_toast").show(_("Highlight sync queued"), { icon = "hourglass" })
end
function BookOrbit:retryOpenBookMatch()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
require("bookorbit_toast").error(_("No book is open"))
return
end
local digest = self:getDocumentDigest()
if not digest then
require("bookorbit_toast").error(_("Could not identify the open book"))
return
end
BookOrbitStateManager.mutateScoped({
digests = { digest },
global = false,
}, function(state)
state.unmatched[digest] = nil
-- An explicit rematch must not be answered from the freshness stamp.
BookOrbitState.expireMatch(state:getBook(digest))
end)
self:requestOpenBookMatch("diagnostics", {
schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
require("bookorbit_toast").show(_("Match retry queued"), { icon = "hourglass" })
end
function BookOrbit:submitSyncJob(job)
if job.family ~= "update_check" then
self:deferAutomaticUpdateCheck()
end
local prepared_job = BookOrbitSyncJobRunner.prepare(job)
local result = self:getSyncCoordinator():submit(prepared_job)
if (result == "queued" or result == "kept") and job.interactive then
if job.on_queued then
job.on_queued()
else
Toast.show(_("BookOrbit sync queued"), { icon = "hourglass" })
end
end
return result
end
function BookOrbit:requestUpdateCheck(interactive, source)
if interactive and not self:isLoggedIn() then
self:promptLogin()
return
end
if interactive then
NetworkMgr:runWhenConnected(function()
self:runInSyncCoroutine(function()
self:doCheckForUpdate()
end)
end)
return
end
if not NetworkMgr:isConnected() then return end
if self.automatic_update_check_scheduled or self._checking_update then return end
self.automatic_update_check_scheduled = true
UIManager:nextTick(function()
self.automatic_update_check_scheduled = false
-- Always consume the deferral here. Re-deferring is driven by the
-- coordinator being busy right now, never by a stale flag, so the
-- check can never be suppressed by a deferral that no job will clear.
local deferred_source = self.automatic_update_check_source
self.automatic_update_check_pending = false
self.automatic_update_check_source = nil
if not NetworkMgr:isConnected() then return end
if self:getSyncCoordinator():isBusy() then
self.automatic_update_check_pending = true
self.automatic_update_check_source = source or deferred_source
return
end
self:runInSyncCoroutine(function()
self:maybeCheckForUpdate(false)
end)
end)
end
function BookOrbit:runPendingUpdateCheck()
if not self.automatic_update_check_pending then return end
local source = self.automatic_update_check_source or "deferred"
self.automatic_update_check_pending = false
self.automatic_update_check_source = nil
self:requestUpdateCheck(false, source)
end
function BookOrbit:deferAutomaticUpdateCheck()
if self.automatic_update_check_scheduled then
self.automatic_update_check_pending = true
end
end
function BookOrbit:requestProgressPush(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_push",
label = _("Progress push"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:updateProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestProgressPull(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_pull",
label = _("Progress pull"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:getProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestAnnotationExchange(source)
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(source or "auto")
done()
end
if NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(execute)
end) then
return
end
execute()
end,
}
end
function BookOrbit:scheduleOpenHighlightRetry(reason, retry_count)
retry_count = retry_count or 0
if retry_count >= OPEN_HIGHLIGHT_MAX_RETRIES then
self.open_highlight_retry_status = nil
return
end
self.open_highlight_retry_status = {
pending = true,
reason = reason or "annotation_retry",
retry_count = retry_count + 1,
max_retries = OPEN_HIGHLIGHT_MAX_RETRIES,
}
UIManager:scheduleIn(OPEN_HIGHLIGHT_RETRY_DELAY, function()
self.open_highlight_retry_status = nil
if self:shouldSkipAutoSyncOffline("annotation_retry") then return end
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = reason or "annotation_retry",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(reason or "annotation_retry", retry_count + 1)
done()
end
if NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(execute)
end) then
return
end
execute()
end,
}
end)
end
function BookOrbit:requestOpenBookMatch(source, opts)
opts = opts or {}
self:submitSyncJob{
family = "match_open_book",
label = _("Open book match"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
self:matchOpenBookForAutoSync(function(matched)
done()
if not matched then return end
if opts.schedule_annotation_sync and self.settings.annotation_sync then
self:scheduleOpenAnnotationSync(opts.annotation_digest or self:getDocumentDigest(), "annotation_open")
end
self:requestProgressPull(true, false, "reader_ready")
end)
end,
}
end
-- opts: full_recheck; quiet with on_progress(text) and on_done(err) when a
-- BookOrbit page shows the run itself instead of KOReader dialogs.
function BookOrbit:requestSweep(interactive, source, opts)
opts = opts or {}
local submit = function()
self:submitSyncJob{
family = "sweep",
label = opts.full_recheck and _("Book match recheck") or _("Library sync"),
source = source or (interactive and "manual" or "auto"),
priority = interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto,
interactive = interactive == true,
async = true,
on_queued = opts.quiet and opts.on_progress and function()
opts.on_progress(_("Waiting for another sync"))
end or nil,
run = function(done)
local started = BookOrbitSweep.run{
api = self:apiOpts(true),
interactive = interactive == true,
quiet = opts.quiet == true,
on_progress = opts.on_progress,
full_recheck = opts.full_recheck == true,
annotation_sync = self.settings.annotation_sync,
plugin = self,
on_finish = function(err)
if not err then
self:requestUpdateCheck(false, "sweep_done")
end
done()
if opts.on_done then pcall(opts.on_done, err) end
end,
}
if started == false then
done()
if opts.on_done then pcall(opts.on_done, "not_started") end
end
end,
}
end
if NetworkMgr:willRerunWhenConnected(submit) then
return
end
submit()
end
function BookOrbit:requestManualBookSync(snap, opts)
if not snap then return end
opts = opts or {}
local api_opts = self:apiOpts(true)
local function finished(err)
if opts.on_done then pcall(opts.on_done, err) end
end
self:submitSyncJob{
family = "book_snapshot",
label = _("Book sync"),
source = "manual",
priority = SYNC_JOB_PRIORITY.manual,
interactive = true,
async = true,
on_queued = opts.quiet and function() end or nil,
run = function(done)
local function run_book_sync(skip_progress, after_sync)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
Toast.error(_("Could not read this book's data"))
done()
finished("capture")
return
end
if latest_snap.digest ~= snap.digest then
Toast.error(_("The open book changed. Sync again"))
done()
finished("changed")
return
end
local started = BookOrbitBookSync.run{
api = api_opts,
snap = latest_snap,
reason = "manual",
interactive = true,
quiet = opts.quiet == true,
plugin = self,
annotation_sync = self.settings.annotation_sync,
skip_progress = skip_progress,
on_finish = function(err)
done()
finished(err)
if not err and after_sync then after_sync() end
end,
}
if started == false then
done()
finished("not_started")
end
end
local run = function()
self:syncBookThenProgress(snap.digest, run_book_sync)
end
if NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(run)
end) then
return
end
run()
end,
}
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)
-- Home opens at startup like "filemanager"; a resumed book stays open and
-- closing it comes back to BookOrbit.
if mode == "home" then mode = "filemanager" end
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 promptLogin()
Toast.error(_("Sign in to BookOrbit first"))
end
-- Signed out, anything that needs the server opens the sign-in page.
function BookOrbit:promptLogin()
if self.openSignIn then
self:openSignIn()
return
end
promptLogin()
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_this_book",
{ category = "none", event = "BookOrbitThisBook", title = _("BookOrbit: this book"), reader = true })
-- The action id predates the rename and is kept for existing gestures.
Dispatcher:registerAction("bookorbit_open_dashboard",
{ category = "none", event = "BookOrbitOpenDashboard", title = _("BookOrbit: open"), general = true, separator = true })
end
function BookOrbit:onReaderReady()
-- Primed here so the close and suspend handlers, which are on a hard
-- latency budget, never open statistics.sqlite3 to learn the row ids.
-- Only those handlers consume it, and they run only while logged in.
if self:isLoggedIn() then
UIManager:nextTick(function()
BookOrbitStatsReader.primeIdentity(self:getDocumentDigest())
end)
end
if self.settings.auto_sync then
UIManager:nextTick(function()
self:requestLifecycleOutboxDrain("book_open")
if self:shouldSkipAutoSyncOffline("reader_ready") then return end
local digest = self:getDocumentDigest()
if self.settings.annotation_sync then
if self:isOpenBookMatched(digest) then
self:scheduleOpenAnnotationSync(digest, "annotation_open")
end
end
self:requestOpenBookMatch("reader_ready", {
schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
end)
end
BookOrbitNoteStamp.install(self.ui)
self:registerEvents()
self.last_page = self.ui:getCurrentPage()
end
local function titleFromFile(file)
if not file or file == "" then return nil end
local name = file:gsub(".*/", "")
if name == "" then return nil end
local title = name:gsub("%.[^%.]+$", "")
return title ~= "" and title or name
end
function BookOrbit:matchOpenBookForAutoSync(on_done)
if not self:isLoggedIn() or not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local digest = self:getDocumentDigest()
if not digest then
if on_done then on_done(false) end
return
end
local state = BookOrbitStateManager.session({
digests = { digest },
files = { self.ui.document.file },
})
local local_book = state:getBook(digest)
local had_local_match = local_book ~= nil
-- Any local match used to end this path, with no freshness bound at all, so
-- a book whose server-side file was deleted or re-imported could stay
-- wrongly matched forever. The bound here is a correctness fix.
if BookOrbitState.isMatchFresh(state:getBook(digest), state.global) then
if on_done then on_done(true) end
return
end
local run = function()
if not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local body, err = self:newClient():matchCheck({ digest }, {
[digest] = {
title = titleFromFile(self.ui.document.file),
source = "current_file",
book_file_id = local_book and local_book.fileId or nil,
},
})
if not body then
self:recordSyncError("match_open_book", err)
-- A failed re-verification must not demote a book that is matched
-- locally; it just stays due for the next attempt.
if on_done then on_done(had_local_match) end
return
end
BookOrbitState.applyLibraryVersion(state, body.libraryVersion)
local matched = false
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file)
matched = true
break
end
end
state:flush()
if on_done then on_done(matched) end
end
if self:shouldSkipAutoSyncOffline("match_open_book") then
if on_done then on_done(false) end
return
end
if NetworkMgr:willRerunWhenConnected(function()
self:runInSyncCoroutine(run)
end) then
return
end
self:runInSyncCoroutine(run)
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(reason, retry_count)
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 = BookOrbitStateManager.session({
digests = { digest },
global = false,
})
if not state:getBook(digest) then
-- Unknown or unmatched book: the close-path snapshot sync matches it.
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = 1,
}, "unmatched")
return
end
self.annotation_exchange_running = true
local client = self:newClient()
-- Decided once for both exchanges: the highlight exchange fills a new sidecar
-- and the marker is written after both, so a dogear exchange that looked
-- again would no longer see the sidecar as new.
local sidecar_state = BookOrbitSidecar.sidecarState(BookOrbitSidecar.isMarked(self.ui.doc_settings),
#(self.ui.annotation and self.ui.annotation.annotations or {}))
local ok, result, err = pcall(BookOrbitAnnotations.exchangeOpenBook, {
client = client,
state = state,
digest = digest,
ui = self.ui,
sidecar_state = sidecar_state,
})
local bm_ok, bm_result, bm_err
local bm_done = true
if BookOrbitBookmarks.enabled(client, self.settings.annotation_sync) then
bm_ok, bm_result, bm_err = pcall(BookOrbitBookmarks.exchangeOpenBook, {
client = client,
state = state,
digest = digest,
ui = self.ui,
sidecar_state = sidecar_state,
})
if bm_ok and bm_err == "unsupported_server" then
BookOrbitBookmarks.markUnsupported(client)
else
bm_done = bm_ok and bm_result ~= nil and not bm_result.had_errors
end
end
if ok and result and not result.had_errors and bm_done then
BookOrbitSidecar.markLive(self.ui.doc_settings)
end
state:flush()
self.annotation_exchange_running = false
local summary = { event = "open_book", reason = reason or "auto" }
local error_code
if not ok then
logger.err("BookOrbit: annotation exchange error:", result)
summary.failed = 1
error_code = "partial_failure"
elseif result then
summary = BookOrbitHighlightSummary.add(summary, result)
elseif err then
summary.skipped = err == "unmatched" and 1 or 0
summary.failed = (err == "network" or err == "unsupported_server") and 1 or 0
error_code = err
if err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end
end
if bm_ok == false then
logger.err("BookOrbit: bookmark exchange error:", bm_result)
summary = BookOrbitHighlightSummary.addBookmarks(summary, { had_errors = true })
error_code = error_code or "partial_failure"
elseif bm_result then
summary = BookOrbitHighlightSummary.addBookmarks(summary, bm_result)
elseif bm_err and bm_err ~= "unmatched" and bm_err ~= "unsupported_server" then
summary = BookOrbitHighlightSummary.addBookmarks(summary, { had_errors = true })
error_code = error_code or bm_err
end
self:recordHighlightSync(summary, error_code)
if BookOrbitHighlightSummary.hasRemoteChanges(summary) then
Toast.show(T(_("%1 highlight(s) updated"), summary.applied + summary.deleted))
end
if BookOrbitHighlightSummary.hasRemoteBookmarkChanges(summary) then
Toast.show(T(_("%1 bookmark(s) updated"), summary.bm_applied + summary.bm_deleted))
end
if ok and result then
if reason == "annotation_open" and (summary.failed or 0) > 0 then
self:scheduleOpenHighlightRetry(reason, retry_count or 0)
elseif (summary.failed or 0) == 0 then
self.open_highlight_retry_status = nil
end
end
end
-- Catalog browser
function BookOrbit:openCatalogBrowser(prefer_cached_dashboard)
if self.catalog_browser ~= nil then return end
self.catalog_browser = BookOrbitCatalog:new{
title = _("BookOrbit"),
-- The catalog owns its subprocess boundaries explicitly, so every
-- request path, including the ones that bypass fetch(), runs off the
-- UI thread.
api = self:apiOpts(true),
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:openMenuSheet(catalog)
end,
_manager = self,
close_callback = function()
UIManager:close(self.catalog_browser)
self.catalog_browser = nil
end,
}
UIManager:show(self.catalog_browser)
end
-- Opening never waits for a connection: the catalog shows the cached dashboard
-- at once and asks for one itself when it refreshes. An offline-tolerant open
-- (the auto-open) does not ask at all.
function BookOrbit:browseCatalog(prefer_cached_dashboard)
if not self:isLoggedIn() then
self:promptLogin()
return
end
self:openCatalogBrowser(prefer_cached_dashboard == true)
end
-- Manual sync triggers
function BookOrbit:startSweep(opts)
if not self:isLoggedIn() then
self:promptLogin()
return
end
self:requestLifecycleOutboxDrain("manual", true)
self:requestSweep(true, "manual", opts)
end
-- Maintenance counterpart of the incremental sweep: rechecks every known hash
-- against the server instead of only the ones local state cannot vouch for.
-- opts as for startSweep.
function BookOrbit:startMatchRecheck(opts)
if not self:isLoggedIn() then
self:promptLogin()
return
end
opts = opts or {}
opts.full_recheck = true
self:requestSweep(true, "recheck", opts)
end
-- opts (from the This book sheet): quiet, on_done(err).
function BookOrbit:onBookOrbitSyncBook(opts)
if type(opts) ~= "table" then opts = nil end
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
Toast.error(_("No book is open"))
return
end
local snap = BookOrbitBookSync.capture(self)
if not snap then
Toast.error(_("Could not read this book's data"))
return
end
self:requestLifecycleOutboxDrain("manual", true)
self:requestManualBookSync(snap, opts)
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
self:deferAutomaticUpdateCheck()
if not self:isLoggedIn() then return end
-- The board's Chapter line draws this book's contents once it has closed.
pcall(function() BookOrbitChapterMap.remember(self.ui, self:getDocumentDigest()) 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
if not self:enqueueLifecycleSnapshot(snap, "close") then return end
self:requestLifecycleOutboxDrain("close")
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
if self:shouldSkipAutoSyncOffline("resume") then return end
UIManager:scheduleIn(1, function()
self:requestProgressPull(true, false, "resume")
end)
end
function BookOrbit:_onSuspend()
logger.dbg("BookOrbit: onSuspend")
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
self:deferAutomaticUpdateCheck()
if not self:isLoggedIn() then return end
local snap = BookOrbitBookSync.capture(self)
if not snap then return end
if not self:enqueueLifecycleSnapshot(snap, "suspend") then return end
self:requestLifecycleOutboxDrain("suspend")
end
function BookOrbit:_onNetworkConnected()
logger.dbg("BookOrbit: onNetworkConnected")
UIManager:scheduleIn(0.5, function()
if self:shouldSkipAutoSyncOffline("network_connected") then return end
self:requestLifecycleOutboxDrain("network_connected")
self:requestProgressPull(false, false, "network_connected")
self:requestUpdateCheck(false, "network_connected")
end)
end
function BookOrbit:_onNetworkDisconnecting()
logger.dbg("BookOrbit: onNetworkDisconnecting")
if self:shouldSkipAutoSyncOffline("network_disconnecting") then return end
self:requestProgressPush(false, false, "network_disconnecting", SYNC_JOB_PRIORITY.lifecycle)
end
function BookOrbit:onBookOrbitPushProgress()
self:requestProgressPush(true, true, "manual")
end
function BookOrbit:onBookOrbitPullProgress()
self:requestProgressPull(true, true, "manual")
end
function BookOrbit:onBookOrbitSyncNow()
self:startSweep()
end
function BookOrbit:onBookOrbitOpenDashboard()
if self.catalog_browser then
-- Already open: the shortcut opens its menu instead.
if UIManager:getTopmostVisibleWidget() == self.catalog_browser then self:openMenuSheet(self.catalog_browser) end
return true
end
self:browseCatalog(false)
return true
end
function BookOrbit:onBookOrbitThisBook()
if not self:isLoggedIn() then
self:promptLogin()
elseif self.ui and self.ui.document then
self:openBookSheet()
end
return true
end
-- "Make BookOrbit home": the file browser a closed book returns to opens
-- BookOrbit over itself before its first paint, so it never flashes.
local RETURN_HOME_WINDOW = 10
function BookOrbit:onShow()
if self.in_reader then return end
local closed_at = PluginShare.bookorbit_return_home
if not closed_at then return end
PluginShare.bookorbit_return_home = nil
if os.time() - closed_at > RETURN_HOME_WINDOW then return end
if self.settings.catalog_auto_open ~= "home" or not self:isLoggedIn() or self.catalog_browser then return end
self:openCatalogBrowser(true)
end
-- quiet: toggled from a BookOrbit page, which shows the result itself, so the
-- first pull and the last push run without their own messages.
function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu, quiet)
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
require("bookorbit_dialog_ui").notice{
title = _("Auto sync needs Wi-Fi to turn on by itself"),
text = _("Open KOReader's Network settings, set 'Action when Wi-Fi is off' to 'Turn on', then turn Auto sync on again."),
}
return true
end
self.settings.auto_sync = not self.settings.auto_sync
G_reader_settings:flush()
self:registerEvents()
if self.settings.auto_sync then
self:requestProgressPull(true, not quiet, "auto_enabled")
else
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false
self.page_update_counter = 0
if from_menu then
self:requestProgressPush(true, not quiet, "auto_disabled")
end
end
if not from_menu then
Toast.show(self.settings.auto_sync and _("Auto sync on") or _("Auto sync off"))
end
return true
end
function BookOrbit:registerEvents()
if not self.in_reader then
-- No book here, but closes queued while offline still go out when Wi-Fi returns.
self.onNetworkConnected = self.settings.auto_sync and self._onNetworkConnected or nil
return
end
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.in_reader and self.settings.catalog_auto_open == "home" then
PluginShare.bookorbit_return_home = os.time()
end
if self.periodic_push_task then
UIManager:unschedule(self.periodic_push_task)
self.periodic_push_task = nil
end
if self.lifecycle_retry_task then
UIManager:unschedule(self.lifecycle_retry_task)
self.lifecycle_retry_task = nil
end
end
-- Real teardown, unlike document close: the sweep is a module singleton that
-- deliberately outlives a reader session, but nothing should keep scheduling
-- chunks or hold the statistics connection into a shutdown. Cancelling here
-- also flushes whatever the server already acknowledged.
-- Returns nothing on purpose: a truthy return would consume the broadcast
-- before the widgets that actually handle shutdown see it.
function BookOrbit:tearDownBackgroundWork()
BookOrbitSweep.cancel("teardown")
end
BookOrbit.onPowerOff = BookOrbit.tearDownBackgroundWork
BookOrbit.onReboot = BookOrbit.tearDownBackgroundWork
return BookOrbit