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