Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
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