--[[-- Main menu mixin for the BookOrbit plugin. KOReader's side of BookOrbit: an icon in KOReader's top menu bar that opens BookOrbit in one tap (in a book, the This book sheet), a trimmed Tools entry for anyone who turns the icon off, and the openers for BookOrbit's own menu sheet, settings, sync status and sign-in pages. Those pages load on first use. Installed onto the plugin controller as regular methods. ]] local Device = require("device") local InfoMessage = require("ui/widget/infomessage") local NetworkMgr = require("ui/network/manager") local UIManager = require("ui/uimanager") local md5 = require("ffi/sha2").md5 local T = require("ffi/util").template local _ = require("gettext") local BookOrbitApi = require("bookorbit_api") local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics") local BookOrbitSweep = require("bookorbit_sweep") local BoardStore = require("bookorbit_board_store") local MainMenu = {} local function formatBool(value) return value and _("On") or _("Off") end local function formatTime(ts) ts = tonumber(ts) if not ts or ts <= 0 then return _("never") end return os.date("%Y-%m-%d %H:%M", ts) end local function formatShortTime(ts) ts = tonumber(ts) if not ts or ts <= 0 then return _("never") end return os.date("%H:%M", ts) end local function deviceIdPrefix(device_id) if type(device_id) ~= "string" or device_id == "" then return _("unknown") end return device_id:sub(1, 8) end local function versionText(version) if type(version) ~= "string" or version == "" then return _("unknown") end return "v" .. version:gsub("^v", "") end local function recordText(entry) if type(entry) ~= "table" then return _("none") end local when = formatTime(entry.at) local message = entry.message or entry.event or _("unknown") return T(_("%1 at %2"), tostring(message), when) end local function highlightRecordText(entry) return BookOrbitHighlightDiagnostics.lastSyncText(entry, formatShortTime) end local function jobText(job) if type(job) ~= "table" then return _("none") end return tostring(job.label or job.family or _("unknown")) end local function jobStartedText(job) if type(job) ~= "table" then return _("none") end return formatTime(job.started_at) end local function safeDocumentDigest(plugin, has_open_book) if not has_open_book or not plugin.getDocumentDigest then return nil end local ok, digest = pcall(plugin.getDocumentDigest, plugin) if ok then return digest end end local function hasReaderBook(plugin) return plugin.ui and plugin.ui.document ~= nil end local function hasCredentials(settings) return settings.server_url ~= nil and settings.username ~= nil and settings.userkey ~= nil end -- What the sync knows, in sections for the Technical details page. Each -- section: title and rows ({ label, value }); actions: { kind, label, icon }. function MainMenu:diagnosticsSections() local status = BookOrbitSweep.syncStatus() local sync_status = self.getSyncCoordinatorStatus and self:getSyncCoordinatorStatus() or { pending_count = 0 } local outbox_status = self.getLifecycleOutboxStatus and self:getLifecycleOutboxStatus() or { count = 0, bytes = 0, blocked = 0 } local has_open_book = hasReaderBook(self) local digest = safeDocumentDigest(self, has_open_book) local scheduler = self.open_annotation_scheduler and self.open_annotation_scheduler:status() or nil local logged_in = self:isLoggedIn() local DeviceNames = require("bookorbit_device_names") local model = Device.model local device_name = DeviceNames.label(model) or _("unknown") if model and device_name ~= model then device_name = T(_("%1 (%2)"), device_name, model) end local sections = { { title = _("This device"), rows = { { _("Device"), device_name }, { _("Device ID"), deviceIdPrefix(self.device_id) }, { _("Plugin"), versionText(self.PLUGIN_VERSION) }, { _("Latest plugin"), versionText(self.settings.update_latest_version) }, } }, { title = _("Account"), rows = { { _("Server"), self.settings.server_url or _("not set") }, { _("Username"), self.settings.username or _("not set") }, { _("Login"), logged_in and _("configured") or _("not configured") }, } }, } local actions = {} if not logged_in then if self.settings.last_error then table.insert(sections, { title = _("Last results"), rows = { { _("Last error"), recordText(self.settings.last_error) } } }) end if hasCredentials(self.settings) then table.insert(actions, { kind = "test", label = _("Test connection"), icon = "wifi" }) end return { sections = sections, actions = actions } end -- Short sections first, so the first page is full. table.insert(sections, { title = _("Last results"), rows = { { _("Last sync"), recordText(self.settings.last_sync) }, { _("Last error"), recordText(self.settings.last_error) }, } }) table.insert(sections, { title = _("Library"), rows = { { _("Last sweep"), formatTime(status.lastSweepAt) }, { _("Matched local books"), tostring(status.matched or 0) }, { _("Unmatched local books"), tostring(status.unmatched or 0) }, } }) table.insert(sections, { title = _("Sync"), rows = { { _("Auto sync"), formatBool(self.settings.auto_sync) }, { _("Two-way highlights & bookmarks"), formatBool(self.settings.annotation_sync) }, { _("Skip offline auto-sync"), formatBool(self.settings.skip_sync_when_offline) }, { _("Current sync"), jobText(sync_status.current) }, { _("Current sync started"), jobStartedText(sync_status.current) }, { _("Next sync"), jobText(sync_status.next) }, { _("Pending syncs"), tostring(sync_status.pending_count or 0) }, { _("Pending lifecycle syncs"), tostring(outbox_status.count or 0) }, { _("Parked lifecycle syncs"), tostring(outbox_status.blocked or 0) }, { _("Lifecycle sync storage"), tostring(outbox_status.bytes or 0) .. " B" }, } }) table.insert(sections, { title = _("Highlights"), rows = { { _("Open highlights"), BookOrbitHighlightDiagnostics.openHighlightsText{ annotation_sync = self.settings.annotation_sync, has_open_book = has_open_book, matched = self.isOpenBookMatched and self:isOpenBookMatched(digest) or false, position_incompatible = self.isOpenBookPositionIncompatible and self:isOpenBookPositionIncompatible(digest) or false, scheduler_status = scheduler, last_highlight_sync = self.settings.last_highlight_sync, } }, { _("Highlight apply retry"), BookOrbitHighlightDiagnostics.retryText(self.open_highlight_retry_status) }, { _("Last highlight sync"), highlightRecordText(self.settings.last_highlight_sync) }, { _("Last bookmark sync"), BookOrbitHighlightDiagnostics.lastBookmarkSyncText(self.settings.last_highlight_sync, formatShortTime) }, } }) if has_open_book then if self.settings.annotation_sync then table.insert(actions, { kind = "retry-highlights", label = _("Retry highlight sync"), icon = "highlighter" }) end table.insert(actions, { kind = "retry-match", label = _("Retry open-book match"), icon = "book-open" }) end table.insert(actions, { kind = "recheck", label = _("Recheck all book matches"), icon = "refresh-cw" }) table.insert(actions, { kind = "test", label = _("Test connection"), icon = "wifi" }) table.insert(actions, { kind = "check-update", label = _("Check for update"), icon = "download" }) return { sections = sections, actions = actions } end -- The same as one flat list: rows, separators between sections and the -- actions as rows that run them. function MainMenu:diagnosticsRows() local d = self:diagnosticsSections() local rows = {} for index, section in ipairs(d.sections) do if index > 1 then table.insert(rows, "----------------------------") end for _i, row in ipairs(section.rows) do table.insert(rows, row) end end if #d.actions > 0 then table.insert(rows, "----------------------------") end local run = { test = function() self:testConnection() end, ["check-update"] = function() self:checkForUpdate() end, recheck = function() self:startMatchRecheck() end, ["retry-highlights"] = function() self:retryOpenHighlightSync() end, ["retry-match"] = function() self:retryOpenBookMatch() end, } for _i, a in ipairs(d.actions) do table.insert(rows, { a.label, _("Run"), callback = run[a.kind] }) end return rows end function MainMenu:showDiagnostics() return require("bookorbit_diagnostics_page").open(self) end -- on_done(ok, reason): a page that shows the result itself; without it the -- result is a message. function MainMenu:testConnection(on_done) if not self.settings.server_url then require("bookorbit_toast").error(_("Set the BookOrbit server address first.")) if on_done then on_done(false, _("No server address")) end return end if not self:isLoggedIn() then if on_done then on_done(false, _("Not signed in")) end self:promptLogin() return end NetworkMgr:runWhenConnected(function() local checking if not on_done then checking = require("bookorbit_toast").loading(_("Testing the connection…"), { dismissable = false }) end local body, err = self:newClient():auth() if checking then checking:close() end if body then self:recordSyncSuccess("connection_test", _("Connection test succeeded")) else self:recordSyncError("connection_test", err) end if on_done then on_done(body ~= nil, not body and self:errorLabel(err) or nil) elseif body then require("bookorbit_toast").show(_("Connection works")) else require("bookorbit_toast").error(T(_("Connection failed: %1"), self:errorLabel(err))) end end) end -- A connection test that reports to the page that started it, on page.test: -- testing, then ok or the reason it failed. The page repaints "Testing" -- before the request, which blocks until the server answers. function MainMenu:startPageTest(page, refresh) page.test = { running = true } refresh() UIManager:tickAfterNext(function() self:testConnection(function(ok, reason) if page.closed then return end page.test = ok and { ok = true } or { err = reason or _("Not reachable") } refresh(true) end) end) end -- ----------------------------------------------------------- menu bar icon function MainMenu:menuBarIconEnabled() return self.settings.menu_bar_icon ~= false end function MainMenu:menuBarIconPath() if not self.path then return nil end local path = self.path .. "/assets/bookorbit.tab.svg" if path:sub(1, 1) ~= "/" then local cwd = require("libs/libkoreader-lfs").currentdir() if cwd then path = cwd .. "/" .. path end end return path end -- KOReader's tab bar looks icons up by name under its icon folders. Enough -- ".." segments climb from any of them to the root, so the shipped SVG is -- found by its absolute path without copying it anywhere. function MainMenu:menuBarIconName() local path = self:menuBarIconPath() if not path then return "appbar.menu" end return string.rep("../", 32) .. path:gsub("^/+", ""):gsub("%.svg$", "") end function MainMenu:setMenuBarIconEnabled(enabled) self.settings.menu_bar_icon = enabled == true G_reader_settings:flush() require("bookorbit_menu_pin").refresh(enabled == true) -- The menu rebuilds from the rewritten order the next time it opens. if self.ui and self.ui.menu then self.ui.menu.tab_item_table = nil end end local function closeKOReaderMenu(ui) local menu = ui and ui.menu if not menu then return end if menu.onCloseFileManagerMenu then menu:onCloseFileManagerMenu() elseif menu.onCloseReaderMenu then menu:onCloseReaderMenu() end end -- The menu bar icon: BookOrbit in the file browser, This book in a book, and -- sign-in for either when signed out. Opened after the menu's own close has -- been painted, so a sheet's backdrop is the page, not the menu. function MainMenu:onMenuBarIcon() closeKOReaderMenu(self.ui) UIManager:tickAfterNext(function() if not self:isLoggedIn() then self:openSignIn(function() self:onMenuBarIconAfterSignIn() end) elseif self.ui and self.ui.document then self:openBookSheet() else self:browseCatalog(false) end end) end function MainMenu:onMenuBarIconAfterSignIn() if self.ui and self.ui.document then self:openBookSheet() else self:browseCatalog(false) end end function MainMenu:addToMainMenu(menu_items) local items = { { -- The id predates the rename; scripts and patches look it up. id = "open_dashboard", text = _("Open BookOrbit"), callback = function() UIManager:tickAfterNext(function() self:openBookOrbit() end) end, }, } if self.ui and self.ui.document then table.insert(items, { id = "this_book", text = _("This book"), callback = function() UIManager:tickAfterNext(function() if self:isLoggedIn() then self:openBookSheet() else self:openSignIn() end end) end, }) end table.insert(items, { id = "settings", text = _("Settings"), callback = function() UIManager:tickAfterNext(function() self:openSettings(self.catalog_browser) end) end, }) 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 = items, } if self:menuBarIconEnabled() then -- A tab that acts as a button, like the reader's file browser tab. -- BookOrbitMenuPin lists it in KOReader's menu bar order. menu_items.bookorbit_tab = { icon = self:menuBarIconName(), remember = false, callback = function() self:onMenuBarIcon() end, } end end -- ----------------------------------------------------------------- openers function MainMenu:openBookOrbit() if not self:isLoggedIn() then self:openSignIn(function() self:browseCatalog(false) end) return end self:browseCatalog(false) end function MainMenu:openSignIn(on_signed_in) return require("bookorbit_signin_page").open(self, on_signed_in) end function MainMenu:openMenuSheet(catalog) return require("bookorbit_menu_sheet").open(self, catalog, function(page, action) return self:onMenuSheetAction(page, catalog, action) end) end function MainMenu:openSettings(catalog, view) return require("bookorbit_settings_page").open(self, catalog, view) end function MainMenu:openSyncStatus() return require("bookorbit_status_page").open(self) end function MainMenu:openBookSheet() if not (self.ui and self.ui.document) then return end return require("bookorbit_book_sheet").open(self) end -- The open book's title, for the sheet's Back to book tile. function MainMenu:openBookTitle() local ui = self.ui if not (ui and ui.document) then return nil end local props = ui.doc_props or {} local title = props.display_title or props.title if title and title ~= "" then return title end local file = ui.document.file or "" return (file:gsub(".*/", ""):gsub("%.[^%.]+$", "")) end function MainMenu:openBookDetails(book_id) if not book_id then return end self:browseCatalog(false) local catalog = self.catalog_browser if catalog and catalog.loadBookDetail then UIManager:nextTick(function() catalog:loadBookDetail(book_id) end) end end -- What the sheet does with a tap it does not handle itself. function MainMenu:onMenuSheetAction(page, catalog, action) local kind = action.kind local after = function(fn) page:close() UIManager:nextTick(fn) end if kind == "sync" or kind == "connect" then -- The sheet stays open and its Sync tile follows the run. local start = function() self:startSheetSync(page, catalog) end if kind == "connect" then NetworkMgr:runWhenConnected(start) else start() end elseif kind == "sync-stop" then self:confirmStopSync() elseif kind == "customize" then page:close() self:customizeHome(catalog) elseif kind == "home" then after(function() if catalog and catalog.goDashboard then catalog:goDashboard() end end) elseif kind == "exit" then after(function() if catalog then catalog:onCloseAllMenus() end end) elseif kind == "files" then self.skip_return_home_once = true after(function() if catalog then catalog:onCloseAllMenus() end UIManager:nextTick(function() UIManager:sendEvent(require("ui/event"):new("Home")) end) end) elseif kind == "search" then after(function() if catalog then catalog:onSearchButtonTap() end end) elseif kind == "link" then after(function() if catalog and catalog.boardOpenLink then catalog:boardOpenLink(action.link) end end) elseif kind == "list-options" then after(function() if not catalog then return end if catalog.listMode and catalog:listMode() then return catalog:listOpenSheet("sort") end catalog:showBookActions() end) elseif kind == "settings" then after(function() self:openSettings(catalog) end) elseif kind == "status" then after(function() self:openSyncStatus() end) elseif kind == "about" then after(function() self:openSettings(catalog, "about") end) elseif kind == "update" then after(function() self:applyUpdate(self.settings.update_latest_version) end) end return true end -- ------------------------------------------------------------- page syncs -- Short words for how a sync started from a page ended. function MainMenu:syncErrorLabel(err) if err == "cancelled" then return _("Stopped") end if err == "not_started" then return _("Another sync is running") end if err == "unmatched" then return _("Not in your BookOrbit library") end if err == "capture" or err == "changed" then return _("Could not read this book") end return self:errorLabel(err) end function MainMenu:confirmStopSync() require("bookorbit_dialog_ui").confirm{ title = _("Stop syncing?"), text = _("What has synced so far is kept. The rest syncs next time."), ok_label = _("Stop"), on_ok = function() BookOrbitSweep.cancel("user") end, } end -- A library sync that reports to the page that started it: progress on -- page.sync while it runs, then the outcome. A page closed in the meantime -- hands the outcome to a notification instead. -- opts.full_recheck runs Recheck all books instead. function MainMenu:startPageSync(page, refresh, opts) if not self:isLoggedIn() then self:promptLogin() return end local recheck = opts and opts.full_recheck page.sync = { running = true, text = recheck and _("Rechecking") or _("Starting"), recheck = recheck } refresh() local start = recheck and self.startMatchRecheck or self.startSweep start(self, { quiet = true, on_progress = function(text) if page.closed then return end page.sync = { running = true, text = text, recheck = recheck } refresh() end, on_done = function(err) local outcome = err and { err = self:syncErrorLabel(err), recheck = recheck } or { done = true, recheck = recheck } if page.closed then local Toast = require("bookorbit_toast") if err then Toast.error(T(_("Sync stopped: %1"), outcome.err)) else Toast.show(recheck and _("Books rechecked") or _("Library synced")) end return end page.sync = outcome refresh(true) end, }) end function MainMenu:startSheetSync(page, catalog) local MenuSheet = require("bookorbit_menu_sheet") self:startPageSync(page, function(reload) if reload then page.sheet_data = MenuSheet.data(self, catalog) end page.sheet_data.sync = page.sync page:refresh() end) end -- ------------------------------------------------------------------ Home function MainMenu:customizeHome(catalog) if not catalog then self:openCatalogBrowser(false) catalog = self.catalog_browser end if not catalog then return end UIManager:nextTick(function() if catalog.goDashboard and not catalog:dashboardMode() then catalog:goDashboard() end if catalog.boardEnterEdit then catalog:boardEnterEdit() end end) end -- Writes a board setting through the open catalog, so the board redraws, or -- straight to the settings when it is closed. function MainMenu:applyBoardSetting(catalog, change) local state = change(self.settings) if catalog and catalog.persistSetting then catalog:persistSetting(BoardStore.SETTING_KEY, state) catalog.board_edit = nil if catalog:dashboardMode() then catalog.current_context.board_page = 1 catalog:boardRefreshIfMissing() end else self.settings[BoardStore.SETTING_KEY] = state G_reader_settings:flush() end end function MainMenu:applyBoardTiles(catalog, tiles) if catalog and catalog.boardSetTiles then catalog:boardSetTiles(tiles) else self.settings[BoardStore.SETTING_KEY] = BoardStore.withTiles(self.settings, tiles) G_reader_settings:flush() end end function MainMenu:applyBoardColour(catalog, colour) if catalog and catalog.boardSetColour then catalog:boardSetColour(colour) else self.settings[BoardStore.SETTING_KEY] = BoardStore.withColour(self.settings, colour) G_reader_settings:flush() end end function MainMenu:chooseDownloadFolder(catalog, on_done) if catalog and catalog.chooseDownloadDir then catalog:chooseDownloadDir(on_done) return end require("ui/downloadmgr"):new{ onConfirm = function(path) G_reader_settings:saveSetting("download_dir", path) G_reader_settings:flush() if on_done then UIManager:nextTick(on_done) end end, }:chooseDir(require("bookorbit_download_root").get()) end -- ---------------------------------------------------------------- shortcut function MainMenu:gesturesData() local gestures = self.ui and self.ui.gestures return gestures and gestures.data or nil end function MainMenu:applyShortcut(id) local ok = require("bookorbit_shortcut").apply(self.ui and self.ui.gestures, id) if not ok then require("bookorbit_toast").error(_("Turn on KOReader's Gestures plugin first: Settings, Taps and gestures.")) end return ok end function MainMenu:refreshActiveCatalogApi(refresh) local api = self:apiOpts(true) if not self.catalog_browser then return end self.catalog_browser:setApi(api) if refresh then self.catalog_browser:refreshCurrent() end end function MainMenu:applyServerAddress(server_url) if self.settings.server_url ~= server_url then self:clearAccountCaches() end self.settings.server_url = server_url self:refreshActiveCatalogApi(false) end -- The server address in the BookOrbit text panel; a value that is not a web -- address keeps the panel open with the reason under the field. function MainMenu:setServerAddress(on_done) local DialogUI = require("bookorbit_dialog_ui") local INK = require("bookorbit_board_metrics").INK local current = self.settings.server_url require("bookorbit_text_entry").open{ title = _("Server address"), value = current and (current:gsub("/api/v1/?$", "")) or "https://", hint = "https://books.example.com", icon = "globe", validate = function(value) if not BookOrbitApi.normalizeServerUrl(value) then return _("Enter a valid http:// or https:// server address.") end end, below = function(_page, w) return { DialogUI.note(w, "info", _("The address you open BookOrbit at in a browser."), { col = INK.g3 }), DialogUI.pillRow(w, { { label = _("Cancel"), kind = "outline", action = { kind = "close" } }, { label = _("Save"), icon = "check", action = { kind = "submit" } }, }), } end, on_submit = function(value) local normalized = BookOrbitApi.normalizeServerUrl(value) self:applyServerAddress(normalized) require("bookorbit_toast").show(T(_("Server set to %1"), require("bookorbit_menu_ui").serverHost(normalized) or normalized)) if on_done then on_done() end end, } end function MainMenu:doLogin(username, password, menu) Device:setIgnoreInput(true) local userkey = md5(password) local opts = self:apiOpts() opts.username = username opts.userkey = userkey local client = BookOrbitApi.new(opts) local body, err = client:auth() Device:setIgnoreInput(false) if body then if self.settings.username ~= username then self:clearAccountCaches() end self.settings.username = username self.settings.userkey = userkey self:refreshActiveCatalogApi(true) if menu then menu:updateItems() end UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") }) UIManager:scheduleIn(1, function() if self.requestUpdateCheck then self:requestUpdateCheck(false, "login") else self:maybeCheckForUpdate(false) end 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 MainMenu:clearAccountCaches() self.settings.catalog_dashboard_cache = nil self.settings.catalog_detail_cache = nil end function MainMenu:logout(menu) self:clearAccountCaches() self.settings.userkey = nil self:refreshActiveCatalogApi(false) if menu then menu:updateItems() end end -- Stores credentials the sign-in page verified. function MainMenu:completeSignIn(server_url, username, userkey) if self.settings.server_url ~= server_url or self.settings.username ~= username then self:clearAccountCaches() end self.settings.server_url = server_url self.settings.username = username self.settings.userkey = userkey G_reader_settings:flush() self:refreshActiveCatalogApi(true) require("bookorbit_toast").show(_("Signed in to BookOrbit")) UIManager:scheduleIn(1, function() if self.requestUpdateCheck then self:requestUpdateCheck(false, "login") end end) end function MainMenu:signOut(catalog) self:logout() G_reader_settings:flush() if catalog then catalog:onCloseAllMenus() end end function MainMenu.install(BookOrbit) for name, fn in pairs(MainMenu) do if name ~= "install" then BookOrbit[name] = fn end end end return MainMenu