--[[-- 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