mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
1a2ffb68c0
A device that had not weighed its open book against BookOrbit since opening dated its place by its last page turn. One turn from a fresh download's cover made another device's further place look older, so Sync now pushed the cover over it without asking, and the phone then reopened the book at its title page. Until the open book's place has been compared with BookOrbit's (a pull or push went through, or Jump or Stay was answered), a further BookOrbit place now also counts as newer, so Sync now and pulls offer Jump instead of overwriting it. Once compared, page-turn times decide as before, and a reset stamped after the last page turn still prompts. Ref #1806
1492 lines
56 KiB
Lua
1492 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 InfoMessage = require("ui/widget/infomessage")
|
|
local NetworkMgr = require("ui/network/manager")
|
|
local Notification = require("ui/widget/notification")
|
|
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.0.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()
|
|
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(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)
|
|
Notification:notify(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)
|
|
Notification:notify(message)
|
|
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 complete = outbox:isComplete(latest)
|
|
if complete then
|
|
outbox:removeEntry(started.id)
|
|
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 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
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync queued."), timeout = 2 })
|
|
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
|
|
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
|
|
done()
|
|
finished("capture")
|
|
return
|
|
end
|
|
if latest_snap.digest ~= snap.digest then
|
|
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
|
|
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()
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Please configure the BookOrbit server and login first."),
|
|
timeout = 3,
|
|
})
|
|
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
|
|
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"),
|
|
summary.applied + summary.deleted))
|
|
end
|
|
if BookOrbitHighlightSummary.hasRemoteBookmarkChanges(summary) then
|
|
Notification:notify(T(_("BookOrbit: %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
|
|
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), 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
|
|
|
|
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
|
|
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
|
|
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
|
|
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 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
|