Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua

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

Archivo original ↗Preguntar a KiBot
--[[--
Dashboard data for the BookOrbit catalog browser.

Fetches the dashboard body and the shelves and name lists the widget board
draws (bookorbit_catalog_board renders it), caches the body for offline
opens, and opens on the cache first so the catalog never waits on the
network.
]]

local NetworkMgr = require("ui/network/manager")
local _ = require("gettext")

local Capabilities = require("bookorbit_capabilities")
local DashboardSections = require("bookorbit_dashboard_sections")

-- Books requested per shelf. Mirrors DASHBOARD_SECTION_LIMIT on the server so
-- a shelf and the dashboard body it may be served from hold the same count.
local SHELF_PAGE_SIZE = 12

local CatalogDashboard = {}

local function isDashboardUnsupported(err)
    return err == 404 or err == 405
end

function CatalogDashboard:dashboardCache()
    local cache = self.settings.catalog_dashboard_cache
    return type(cache) == "table" and cache or nil
end

function CatalogDashboard:cacheDashboard(body)
    if type(body) ~= "table" then return end
    self:persistSetting("catalog_dashboard_cache", body)
end

function CatalogDashboard.dashboardItems()
    return {
        {
            text = _("Dashboard"),
            kind = "dashboard",
        },
    }
end

function CatalogDashboard:dashboardContext(dashboard, opts)
    opts = opts or {}
    return self.dashboardItems(), {
        kind = "dashboard",
        title = self.title,
        dashboard = dashboard,
        stale = opts.stale == true,
        unavailable = opts.unavailable == true,
        loading = opts.loading == true,
    }
end

-- A cached body is served whole. Shelves the board shows that the cache does
-- not hold draw empty until the refresh fills them.
function CatalogDashboard:cachedDashboardContext(cached, opts)
    opts = opts or {}
    opts.stale = true
    return self:dashboardContext(cached, opts)
end

-- What the catalog widget opens on, built without touching the network: the
-- cached dashboard when there is one, otherwise a placeholder the refresh
-- replaces in place. Offline the placeholder says the dashboard could not be
-- loaded, because a connection may never come.
function CatalogDashboard:initialDashboardContext()
    local cached = self:dashboardCache()
    if cached then
        return self:cachedDashboardContext(cached)
    end
    if NetworkMgr:isConnected() then
        return self:dashboardContext(nil, { stale = true, loading = true })
    end
    return self:dashboardContext(nil, { stale = true, unavailable = true })
end

-- An offline-tolerant catalog never asks for a connection on its own. An
-- explicit open is not offline-tolerant until its first refresh has asked.
function CatalogDashboard:shouldRefreshDashboardOnOpen()
    if self.prefer_cached_dashboard and not NetworkMgr:isConnected() then
        return false
    end
    return true
end

-- The refresh behind a dashboard that has just been shown. An explicit open
-- asks for a connection once, through the user's Wi-Fi policy, while the
-- cached dashboard is already on screen. Returning to the dashboard later
-- keeps the cache while offline rather than asking again.
function CatalogDashboard:refreshDashboardOnOpen()
    if self:shouldRefreshDashboardOnOpen() then
        self:loadDashboardRoot(true, { invisible = true })
    end
    self.prefer_cached_dashboard = true
end

-- One loading pill for the dashboard's requests, so it does not blink
-- between them; a background refresh shows none.
function CatalogDashboard:dashboardRoot(opts)
    if (opts and opts.invisible) or self.held_loading or not self.withLoading then
        return self:dashboardRootFetch(opts)
    end
    local items, context
    self:withLoading(_("Updating Home…"), function()
        items, context = self:dashboardRootFetch(opts)
    end)
    return items, context
end

function CatalogDashboard:dashboardRootFetch(opts)
    local function requestIsCurrent()
        return not opts or not opts.is_current or opts.is_current()
    end
    local cached = self:dashboardCache()
    if self.prefer_cached_dashboard and cached and not NetworkMgr:isConnected() then
        return self:cachedDashboardContext(cached)
    end

    local body, err = self:fetch(_("Updating Home…"), function()
        return self.client:catalogDashboard()
    end, opts)
    if body and body.continueReading then
        local function fetchSection(config)
            if not requestIsCurrent() then return nil, "cancelled" end
            -- Gathering dust: this device's never-opened books, found here and
            -- looked up by id for their titles and covers.
            if config.type == "unopened" then
                local unopened = require("bookorbit_board_data").unopened(self, os.time())
                if #unopened == 0 then return { type = config.type, books = {} } end
                local ids = {}
                for _, entry in ipairs(unopened) do table.insert(ids, entry.id) end
                local response, unopened_err = self:fetch(_("Updating Home…"), function()
                    return self.client:catalogBooks({ ids = table.concat(ids, ","), sort = "title", page = 1, size = #ids })
                end, opts)
                if unopened_err or type(response) ~= "table" then return nil, unopened_err or "invalid response" end
                return { type = config.type, books = response.items or {} }
            end
            if config.type == DashboardSections.DEFAULT_TYPE then
                if type(body.discover) == "table" then
                    return { type = config.type, books = body.discover }
                end
                local response, discover_err = self:fetch(_("Updating Home…"), function()
                    return self.client:catalogDiscover()
                end, opts)
                if discover_err or type(response) ~= "table" then
                    return nil, discover_err or "invalid response"
                end
                return { type = config.type, books = response.discover or {} }
            end
            -- Sources without a catalog-books equivalent go through the server's
            -- dashboard-section endpoint. A confirmed 404/405 downgrades the
            -- capability so the picker stops offering them against this server.
            if DashboardSections.usesSectionEndpoint(config.type) then
                local known = Capabilities.cached(self.client)
                if known and not known[DashboardSections.SECTION_ENDPOINT_CAPABILITY] then
                    return { type = config.type, books = {} }
                end
                local response, section_err = self:fetch(_("Updating Home…"), function()
                    return self.client:catalogDashboardSection(config.type)
                end, opts)
                if isDashboardUnsupported(section_err) then
                    Capabilities.markUnsupported(self.client, DashboardSections.SECTION_ENDPOINT_CAPABILITY)
                    return { type = config.type, books = {} }
                end
                if section_err or type(response) ~= "table" then
                    return nil, section_err or "invalid response"
                end
                local section = type(response.section) == "table" and response.section or {}
                return { type = config.type, books = section.books or {} }
            end

            local params = { page = 1, size = SHELF_PAGE_SIZE }
            if config.type == "recently-added" then
                params.sort = "recently_added"
            elseif config.type == "in-progress" then
                params.sort = "recently_read"
                params.readStatus = "reading"
            elseif config.type == "pick-back-up" then
                params.sort = "recently_read"
                params.order = "asc"
                params.readStatus = "reading"
            elseif DashboardSections.isCatalogSelector(config.type) then
                for key, value in pairs(config.params or {}) do params[key] = value end
            else
                return nil
            end
            local response, section_err = self:fetch(_("Updating Home…"), function()
                return self.client:catalogBooks(params)
            end, opts)
            if section_err or type(response) ~= "table" then return nil, section_err or "invalid response" end
            return { type = config.type, books = response.items or {} }
        end

        -- The board asks only for the shelves and names its pages show. A
        -- source that failed is left empty rather than failing the dashboard.
        local sources, names = {}, {}
        if self.boardSources then sources, names = self:boardSources() end
        body.boardShelves = {}
        for key, props in pairs(sources) do
            if not requestIsCurrent() then return nil, "cancelled" end
            if key == "in-progress" then
                body.boardShelves[key] = body.continueReading
            elseif key == "finished" then
                body.boardShelves[key] = body.finishedThisYear or {}
            else
                local section, section_err = fetchSection({ type = props.source, params = props.params })
                if section_err == "cancelled" then return nil, section_err end
                body.boardShelves[key] = section_err and {} or (section and section.books or {})
            end
        end
        body.boardNames = {}
        local NAME_SECTIONS = { collections = "collections", scopes = "smart-scopes" }
        for key in pairs(names) do
            if not requestIsCurrent() then return nil, "cancelled" end
            local response = self:fetch(_("Updating Home…"), function()
                return self.client:catalogSection(NAME_SECTIONS[key], { page = 1, size = 4 })
            end, opts)
            local list = {}
            for _, entry in ipairs(type(response) == "table" and response.items or {}) do
                if type(entry.title) == "string" then table.insert(list, entry.title) end
            end
            body.boardNames[key] = list
        end
        self:cacheDashboard(body)
        return self:dashboardContext(body)
    end

    if isDashboardUnsupported(err) then
        if requestIsCurrent() then
            require("bookorbit_toast").show(_("Home needs a newer BookOrbit server. Showing the catalog instead."), { icon = "info", timeout = 4 })
        end
        return self:rootItems(), { kind = "root", title = self.title }
    end

    if cached then
        return self:cachedDashboardContext(cached)
    end

    if err ~= "cancelled" and requestIsCurrent() then
        self:showServerError(err)
    end
    return self:dashboardContext(nil, { stale = true, unavailable = true })
end

function CatalogDashboard:loadDashboardRoot(replace, opts)
    self.dashboard_request_generation = (self.dashboard_request_generation or 0) + 1
    local request_generation = self.dashboard_request_generation
    local expected_context = self.current_context
    local request_opts = {}
    for key, value in pairs(opts or {}) do request_opts[key] = value end
    request_opts.is_current = function()
        return not self.catalog_closed
            and request_generation == self.dashboard_request_generation
            and self.current_context == expected_context
    end
    self:runConnected(function()
        -- The screen said the dashboard could not be loaded while this waited
        -- for a connection, so the fetch shows that it is now loading.
        if expected_context and expected_context.unavailable then
            request_opts.invisible = nil
        end
        local items, context = self:dashboardRoot(request_opts)
        if self.catalog_closed or request_generation ~= self.dashboard_request_generation
                or self.current_context ~= expected_context then
            return
        end
        -- A refresh keeps the board on the page the reader was looking at.
        if context and expected_context and expected_context.board_page then
            context.board_page = expected_context.board_page
        end
        self:switchTo(context.title or self.title, items, context, not replace)
    end)
end

-- Every book the board can draw, used for thumbnail prefetching and
-- cover-cache eviction.
function CatalogDashboard.dashboardBooks(dashboard)
    dashboard = dashboard or {}
    local books = {}
    local seen = {}
    local function add(book)
        if type(book) == "table" and book.id ~= nil and not seen[book.id] then
            seen[book.id] = true
            table.insert(books, book)
        end
    end
    for _, book in ipairs(dashboard.continueReading or {}) do add(book) end
    for _, list in pairs(type(dashboard.boardShelves) == "table" and dashboard.boardShelves or {}) do
        for _, book in ipairs(list) do add(book) end
    end
    for _, book in ipairs(dashboard.discover or {}) do add(book) end
    if type(dashboard.yearAgo) == "table" then add(dashboard.yearAgo.book) end
    return books
end

function CatalogDashboard.install(Catalog)
    for name, fn in pairs(CatalogDashboard) do
        if name ~= "install" then
            Catalog[name] = fn
        end
    end
end

return CatalogDashboard