mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(koreader): support customizable dashboard sections (#820)
* feat(koreader): add dashboard row configuration model * feat(koreader): reuse catalog for dashboard source selection * feat(koreader): configure dashboard rows on 1.4.0 * chore(koreader): preserve dashboard source line endings * fix(koreader): keep BookOrbit tools menu loadable * fix(koreader): keep dashboard requests out of rendering * revert(koreader): restore stock 1.4.0 dashboard renderer * revert(koreader): use stock single dashboard row * fix(koreader): apply selected dashboard row source * feat(koreader): add second dashboard row setting * feat(koreader): render second dashboard row * fix(koreader): fetch configured dashboard rows directly * fix(koreader): honor configured dashboard row sources * feat(koreader): support four configurable dashboard sections * fix(koreader): use real catalog dashboard sources * fix(koreader): select dashboard shelves from catalog * fix(koreader): reuse catalog for dashboard source selection * fix(koreader): close dashboard menu before catalog selection * test(koreader): split dashboard configuration coverage * chore(koreader): bump version to 1.5.0 * fix(koreader): address dashboard review feedback * fix(koreader): address dashboard section review feedback * test(koreader): cover dashboard behavior at runtime * test(koreader): use Nest setup for dashboard DTO * chore(ci): trigger pipeline * fix(koreader): migrate untagged dashboard section settings Plugin 1.4.x stored the configurable dashboard rows as a bare ordered list with no schema marker, so gating the migration on schemaVersion == 1 never matched anything a device had actually written. Every upgrading user who had ever picked a row was read positionally instead: the Stats strip disappeared and Discover ended up in two slots, with a stored SmartScope dropped entirely. Treat an untagged stored list as the legacy shape and migrate it into the slots those rows used to occupy. Cover it with the settings shapes 1.4.x really wrote, rather than a marker nothing ever produced. Also drop store() and primary(), which no caller uses now that slots are written through storeAt(), along with the dead grow loop inside storeAt() that normalize() already makes unreachable. * fix(koreader): keep dashboard shelves in aspect, inside the page, and deduped The four-slot renderer handed each shelf a height derived from the leftover vertical budget while its cover cards kept the width computed from the natural row, so any page with more than one shelf drew covers out of aspect. It also had no answer for a page too small for the configured slots: shelves clamped to a 48px floor and the layout simply overflowed the available height. Size shelves from their cards instead. A shelf that does not fit gets more, narrower cards per row, so its height is always the height those cards need. When even the narrowest row does not fit, drop slots the way the pre-four-slot renderer did: the stats strip first, then shelves from the bottom up. Fetch each distinct shelf source once per dashboard load. Two slots showing Discover previously rendered the same list twice, and two slots on the same catalog filter each paid for their own request; a source that fails is now also not retried by the next slot that wants it. Drop what the four-slot rewrite left behind: the unused capabilities require, the tall-layout helper and its constants, and the section accessors and request helper that no longer have callers. Name the per-shelf page size and the minimum cover width instead of repeating the literals. Add setDashboardSections() so a whole configuration can be applied in one refresh. Add a layout spec that drives updateDashboardItems and asserts covers stay in aspect, the drop order holds, and nothing is laid out past the available height. * fix(koreader): close the dashboard slot menu through its own API Reset to Default wrote section_stale as a boolean where the renderer reads a per-slot table, so the reset never actually marked anything pending and stale books stayed on screen under the new headers until the refresh landed. It also dereferenced current_context without a guard and wrote the setting behind the catalog's back. Route it through the new setDashboardSections() so the slots and the cache signature move together in one refresh. Close the invoking touch menu with closeMenu() rather than handing the widget to UIManager:close(), which skips the close_callback that owns its container. Drop the section parameter from catalogDashboard() and the unused catalogDashboardSection() helper: the plugin builds its shelves from the ordinary catalog endpoints now and sends neither. The server keeps both routes for devices still on 1.4.x, so its DTO test stays; rename it to match the file it covers and drop the empty Nest module compile. * revert(koreader): keep the plugin at 1.4.0 1.4.0 has not shipped yet: 1.3.0 is the last released plugin version, so the dashboard slot work can land inside 1.4.0 rather than claiming a version of its own. Devices in the field are on 1.3.0 and see a single 1.3.0 -> 1.4.0 step either way. * fix(koreader): stop rotating upright read-status badges buildReadStatusBadge turned every status icon a quarter turn, but only two of the seven are corner folds. The dogear assets are drawn with their right angle at the top left and the badge sits in the cover's top-right corner, so those do need the turn. Bookmark, check and reload are upright glyphs and were being drawn on their side: a want-to-read cover showed its bookmark ribbon lying sideways like a chevron. Rotate only the dogear icons. --------- Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -547,26 +547,17 @@ function BookOrbitApi:catalogRoot()
|
||||
return self:request("GET", "/koreader/plugin/catalog/root")
|
||||
end
|
||||
|
||||
-- Naming a section is opt-in: without it the server returns the pre-section
|
||||
-- dashboard, which is what an older server understands.
|
||||
function BookOrbitApi:catalogDashboard(section)
|
||||
local params
|
||||
if section and section.type then
|
||||
params = { section = section.type, smartScopeId = section.smartScopeId }
|
||||
end
|
||||
return self:request("GET", self:query("/koreader/plugin/catalog/dashboard", params))
|
||||
-- The dashboard body carries the stats, Continue reading and Discover the
|
||||
-- shelves are built from. Each configured shelf is then fetched through the
|
||||
-- ordinary catalog endpoints, so nothing here is section-aware.
|
||||
function BookOrbitApi:catalogDashboard()
|
||||
return self:request("GET", "/koreader/plugin/catalog/dashboard")
|
||||
end
|
||||
|
||||
function BookOrbitApi:catalogDiscover()
|
||||
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
|
||||
end
|
||||
|
||||
function BookOrbitApi:catalogDashboardSection(section)
|
||||
if not section or not section.type then return nil, 400 end
|
||||
local path = "/koreader/plugin/catalog/dashboard/sections/" .. util.urlEncode(section.type)
|
||||
return self:request("GET", self:query(path, { smartScopeId = section.smartScopeId }))
|
||||
end
|
||||
|
||||
function BookOrbitApi:catalogSection(section, params)
|
||||
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
|
||||
end
|
||||
|
||||
@@ -390,7 +390,7 @@ function BookOrbitCatalog:refreshCurrent()
|
||||
self:evictCachedCovers(self.dashboardBooks(context.dashboard))
|
||||
self:loadDashboardRoot(true)
|
||||
elseif context.kind == "section" then
|
||||
self:loadSection(context.section, { page = context.page, q = context.q, replace = true })
|
||||
self:reloadSection(context.section, { page = context.page })
|
||||
elseif context.kind == "detail" and context.detail then
|
||||
self:evictCachedCovers(self:detailThumbnailBooks(context.detail))
|
||||
self:runConnected(function()
|
||||
@@ -862,8 +862,14 @@ function BookOrbitCatalog:loadSection(section, opts)
|
||||
table.insert(item_table, {
|
||||
text = entry.title,
|
||||
mandatory = entry.count and tostring(entry.count) or nil,
|
||||
kind = "books",
|
||||
kind = opts.dashboard_source_index and "dashboard-source" or "books",
|
||||
params = entry_params,
|
||||
dashboard_source_index = opts.dashboard_source_index,
|
||||
dashboard_source = opts.dashboard_source_index and {
|
||||
type = section,
|
||||
sourceName = entry.title,
|
||||
params = entry_params,
|
||||
} or nil,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -882,11 +888,26 @@ function BookOrbitCatalog:loadSection(section, opts)
|
||||
paged = paged,
|
||||
has_next = body.hasNext == true,
|
||||
has_previous = page > 1,
|
||||
dashboard_source_index = opts.dashboard_source_index,
|
||||
}, push)
|
||||
end)
|
||||
end
|
||||
|
||||
function BookOrbitCatalog:promptSectionFilter(section, current_q)
|
||||
function BookOrbitCatalog:reloadSection(section, overrides)
|
||||
local context = self.current_context or {}
|
||||
local opts = {
|
||||
q = context.q,
|
||||
replace = true,
|
||||
dashboard_source_index = context.dashboard_source_index,
|
||||
}
|
||||
for key, value in pairs(overrides or {}) do
|
||||
if key ~= "clear_q" then opts[key] = value end
|
||||
end
|
||||
if overrides and overrides.clear_q then opts.q = nil end
|
||||
return self:loadSection(section or context.section, opts)
|
||||
end
|
||||
|
||||
function BookOrbitCatalog:promptSectionFilter(section, current_q, dashboard_source_index)
|
||||
local dialog
|
||||
dialog = InputDialog:new{
|
||||
title = _("Filter"),
|
||||
@@ -905,7 +926,11 @@ function BookOrbitCatalog:promptSectionFilter(section, current_q)
|
||||
text = _("Clear"),
|
||||
callback = function()
|
||||
UIManager:close(dialog)
|
||||
self:loadSection(section, { page = 1, q = nil, replace = true })
|
||||
self:reloadSection(section, {
|
||||
page = 1,
|
||||
clear_q = true,
|
||||
dashboard_source_index = dashboard_source_index,
|
||||
})
|
||||
end,
|
||||
},
|
||||
{
|
||||
@@ -914,7 +939,11 @@ function BookOrbitCatalog:promptSectionFilter(section, current_q)
|
||||
callback = function()
|
||||
local value = util.trim(dialog:getInputText() or "")
|
||||
UIManager:close(dialog)
|
||||
self:loadSection(section, { page = 1, q = value ~= "" and value or nil, replace = true })
|
||||
self:reloadSection(section, {
|
||||
page = 1,
|
||||
q = value ~= "" and value or nil,
|
||||
dashboard_source_index = dashboard_source_index,
|
||||
})
|
||||
end,
|
||||
},
|
||||
},
|
||||
@@ -1432,7 +1461,7 @@ function BookOrbitCatalog:showBookActions()
|
||||
text = context.q and T(_("Filter: %1"), context.q) or _("Filter..."),
|
||||
callback = function()
|
||||
UIManager:close(dialog)
|
||||
self:promptSectionFilter(context.section, context.q)
|
||||
self:promptSectionFilter(context.section, context.q, context.dashboard_source_index)
|
||||
end,
|
||||
},
|
||||
{
|
||||
@@ -1440,7 +1469,7 @@ function BookOrbitCatalog:showBookActions()
|
||||
enabled = context.q ~= nil,
|
||||
callback = function()
|
||||
UIManager:close(dialog)
|
||||
self:loadSection(context.section, { page = 1, q = nil, replace = true })
|
||||
self:reloadSection(context.section, { page = 1, clear_q = true })
|
||||
end,
|
||||
},
|
||||
})
|
||||
@@ -2023,7 +2052,7 @@ function BookOrbitCatalog:onNextPage()
|
||||
end
|
||||
local context = self.current_context
|
||||
if context.has_next then
|
||||
self:loadSection(context.section, { page = (context.page or 1) + 1, q = context.q, replace = true })
|
||||
self:reloadSection(context.section, { page = (context.page or 1) + 1 })
|
||||
end
|
||||
return true
|
||||
elseif self:detailMode() then
|
||||
@@ -2055,7 +2084,7 @@ function BookOrbitCatalog:onPrevPage()
|
||||
end
|
||||
local context = self.current_context
|
||||
if (context.page or 1) > 1 then
|
||||
self:loadSection(context.section, { page = (context.page or 1) - 1, q = context.q, replace = true })
|
||||
self:reloadSection(context.section, { page = (context.page or 1) - 1 })
|
||||
end
|
||||
return true
|
||||
elseif self:detailMode() then
|
||||
@@ -2083,7 +2112,7 @@ function BookOrbitCatalog:onFirstPage()
|
||||
end
|
||||
local context = self.current_context
|
||||
if (context.page or 1) > 1 then
|
||||
self:loadSection(context.section, { page = 1, q = context.q, replace = true })
|
||||
self:reloadSection(context.section, { page = 1 })
|
||||
end
|
||||
return true
|
||||
elseif self:detailMode() then
|
||||
@@ -2138,11 +2167,22 @@ function BookOrbitCatalog:onMenuSelect(item)
|
||||
elseif item.kind == "dashboard-search" then
|
||||
self:promptSearch({})
|
||||
elseif item.kind == "dashboard-reroll" then
|
||||
self:rerollDiscover()
|
||||
self:rerollDiscover(item.section_index)
|
||||
elseif item.kind == "section-filter" then
|
||||
self:promptSectionFilter(item.section, item.q)
|
||||
local context = self.current_context or {}
|
||||
self:promptSectionFilter(item.section, item.q, context.dashboard_source_index)
|
||||
elseif item.kind == "section-page" then
|
||||
self:loadSection(item.section, { page = item.section_page, q = item.q, replace = true })
|
||||
self:reloadSection(item.section, {
|
||||
page = item.section_page,
|
||||
q = item.q,
|
||||
})
|
||||
elseif item.kind == "dashboard-source" then
|
||||
local source = item.dashboard_source
|
||||
if source and type(source.sourceName) == "string" and source.sourceName ~= "" then
|
||||
self:setDashboardSection(source, item.dashboard_source_index)
|
||||
self:onReturn()
|
||||
if self:dashboardMode() then self:refreshCurrent() end
|
||||
end
|
||||
elseif item.kind == "on-device" then
|
||||
self:loadOnDevice()
|
||||
elseif item.kind == "books" then
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
--[[--
|
||||
Dashboard mixin for the BookOrbit catalog browser.
|
||||
|
||||
Builds and renders the single-page dashboard: a summary stats band, a
|
||||
Continue-reading hero row (two hero cards side by side, chevron-paged through
|
||||
all in-progress books), one configurable book row and a compact Browse list,
|
||||
including the offline cache and the Discover reroll. Installed onto the catalog
|
||||
controller as regular methods.
|
||||
Builds and renders the single-page dashboard as four ordered, configurable
|
||||
slots. Stats keeps its compact strip, Continue reading keeps its hero row, book
|
||||
sources and SmartScopes use cover grids, and Browse keeps its action list.
|
||||
|
||||
The configurable row defaults to Discover and can be pointed at another source
|
||||
through bookorbit_dashboard_sections. Every source renders identically, so the
|
||||
layout budget does not depend on which one is chosen.
|
||||
|
||||
Layout is budget-driven: fixed blocks are measured first. When the page gets
|
||||
too tight, the stats strip is dropped first, then the configurable row itself.
|
||||
The renderer measures native slots first, then divides the remaining vertical
|
||||
budget evenly among all configured cover-grid shelves. Cover cards are always
|
||||
sized from their width, so a tight page gives shelves narrower cards rather
|
||||
than squashed ones; only when even the narrowest row does not fit is a slot
|
||||
dropped, stats first. Grid sources carry up to SHELF_PAGE_SIZE books and
|
||||
paginate independently. The mixin also owns offline caching and per-slot
|
||||
Discover rerolls.
|
||||
]]
|
||||
|
||||
local Blitbuffer = require("ffi/blitbuffer")
|
||||
@@ -34,7 +33,6 @@ local VerticalSpan = require("ui/widget/verticalspan")
|
||||
local T = require("ffi/util").template
|
||||
local _ = require("gettext")
|
||||
|
||||
local Capabilities = require("bookorbit_capabilities")
|
||||
local CatalogUtil = require("bookorbit_catalog_util")
|
||||
local CatalogWidgets = require("bookorbit_catalog_widgets")
|
||||
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||
@@ -54,10 +52,13 @@ local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
|
||||
local STATS_CACHE_TTL = 120
|
||||
local SECTION_TARGET_SLOTS = 5
|
||||
local SECTION_COMPACT_GAP = 6
|
||||
local DASHBOARD_TALL_ASPECT_RATIO = 1.55
|
||||
local SECTION_MAX_ROWS = 2
|
||||
local STATS_MIN_BODY_HEIGHT = 56
|
||||
local SECTION_PAGE_ID = "section"
|
||||
local SECTION_PAGE_PREFIX = "section"
|
||||
-- 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
|
||||
-- Cover cards never get narrower than this, however tight the page is.
|
||||
local SHELF_MIN_CARD_WIDTH = 72
|
||||
|
||||
local CatalogDashboard = {}
|
||||
|
||||
@@ -73,46 +74,26 @@ end
|
||||
function CatalogDashboard:cacheDashboard(body)
|
||||
if type(body) ~= "table" then return end
|
||||
self:persistSetting("catalog_dashboard_cache", body)
|
||||
self:persistSetting("catalog_dashboard_cache_section",
|
||||
DashboardSections.signature(self:dashboardBodySection(body)))
|
||||
self:persistSetting("catalog_dashboard_cache_section", DashboardSections.settingsSignature(self.settings))
|
||||
end
|
||||
|
||||
-- True when the cached body holds books for the section the user currently has
|
||||
-- configured. A mismatch leaves everything else in the cache usable; only the
|
||||
-- configurable row has to wait for the refresh.
|
||||
function CatalogDashboard:dashboardCacheMatchesSection()
|
||||
return self.settings.catalog_dashboard_cache_section
|
||||
== DashboardSections.signature(DashboardSections.primary(self.settings))
|
||||
return self.settings.catalog_dashboard_cache_section == DashboardSections.settingsSignature(self.settings)
|
||||
end
|
||||
|
||||
function CatalogDashboard:dashboardConfiguredSection()
|
||||
return DashboardSections.primary(self.settings)
|
||||
function CatalogDashboard:dashboardConfiguredSection(index)
|
||||
return DashboardSections.at(self.settings, index or 1)
|
||||
end
|
||||
|
||||
-- What a response body actually contains, which is not always what is
|
||||
-- configured: a server without the capability answers with the legacy Discover
|
||||
-- row no matter what the user picked, and the header must not claim otherwise.
|
||||
function CatalogDashboard:dashboardBodySection(body)
|
||||
local section = type(body) == "table" and body.section or nil
|
||||
if type(section) ~= "table" or not DashboardSections.isValid(section.type) then
|
||||
return DashboardSections.defaultConfig()
|
||||
end
|
||||
local configured = self:dashboardConfiguredSection()
|
||||
local scope_name
|
||||
if configured.type == "smart-scope" and configured.smartScopeId == section.smartScopeId then
|
||||
scope_name = configured.smartScopeName
|
||||
end
|
||||
return DashboardSections.normalizeEntry({
|
||||
type = section.type,
|
||||
smartScopeId = section.smartScopeId,
|
||||
smartScopeName = scope_name,
|
||||
})
|
||||
function CatalogDashboard.dashboardSlotSection(body, index)
|
||||
if type(body) ~= "table" or type(body.dashboardSlots) ~= "table" then return nil end
|
||||
local section = body.dashboardSlots[index]
|
||||
return type(section) == "table" and section or nil
|
||||
end
|
||||
|
||||
function CatalogDashboard.dashboardSectionBooks(body)
|
||||
if type(body) ~= "table" then return {} end
|
||||
if type(body.section) == "table" then return body.section.books or {} end
|
||||
return body.discover or {}
|
||||
function CatalogDashboard.dashboardSlotBooks(body, index)
|
||||
local section = CatalogDashboard.dashboardSlotSection(body, index)
|
||||
return section and section.books or {}
|
||||
end
|
||||
|
||||
function CatalogDashboard.dashboardItems()
|
||||
@@ -143,7 +124,7 @@ function CatalogDashboard:dashboardContext(dashboard, opts)
|
||||
stale = opts.stale == true,
|
||||
unavailable = opts.unavailable == true,
|
||||
loading = opts.loading == true,
|
||||
section_stale = opts.section_stale == true,
|
||||
section_stale = opts.section_stale,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -152,7 +133,12 @@ end
|
||||
function CatalogDashboard:cachedDashboardContext(cached, opts)
|
||||
opts = opts or {}
|
||||
opts.stale = true
|
||||
opts.section_stale = not self:dashboardCacheMatchesSection()
|
||||
if not self:dashboardCacheMatchesSection() then
|
||||
opts.section_stale = {}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
opts.section_stale[index] = true
|
||||
end
|
||||
end
|
||||
return self:dashboardContext(cached, opts)
|
||||
end
|
||||
|
||||
@@ -180,16 +166,6 @@ function CatalogDashboard:shouldRefreshDashboardOnOpen()
|
||||
return true
|
||||
end
|
||||
|
||||
-- The section parameter is only sent to a server that advertises it. Discover
|
||||
-- needs no parameter at all, so the default configuration never pays for the
|
||||
-- capability probe.
|
||||
function CatalogDashboard:dashboardSectionRequest()
|
||||
local config = self:dashboardConfiguredSection()
|
||||
if config.type == DashboardSections.DEFAULT_TYPE then return nil end
|
||||
if Capabilities.supports(self.client, DashboardSections.CAPABILITY) ~= true then return nil end
|
||||
return config
|
||||
end
|
||||
|
||||
function CatalogDashboard:dashboardRoot(opts)
|
||||
local function requestIsCurrent()
|
||||
return not opts or not opts.is_current or opts.is_current()
|
||||
@@ -199,21 +175,63 @@ function CatalogDashboard:dashboardRoot(opts)
|
||||
return self:cachedDashboardContext(cached)
|
||||
end
|
||||
|
||||
local section = self:dashboardSectionRequest()
|
||||
local body, err = self:fetch(_("Loading dashboard..."), function()
|
||||
return self.client:catalogDashboard(section)
|
||||
return self.client:catalogDashboard()
|
||||
end, opts)
|
||||
-- A server that advertised the capability but rejects the parameter is
|
||||
-- answering definitively, so drop back to the legacy shape for the session
|
||||
-- rather than leaving the dashboard empty.
|
||||
if not body and section and (err == 400 or err == 404) then
|
||||
Capabilities.markUnsupported(self.client, DashboardSections.CAPABILITY)
|
||||
section = nil
|
||||
body, err = self:fetch(_("Loading dashboard..."), function()
|
||||
return self.client:catalogDashboard()
|
||||
end, opts)
|
||||
end
|
||||
if body and body.continueReading then
|
||||
local function fetchSection(config)
|
||||
if not requestIsCurrent() then return nil, "cancelled" 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(_("Loading dashboard..."), 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
|
||||
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 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(_("Loading dashboard..."), 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
|
||||
|
||||
-- Slots pointing at the same source cost one request between them, and
|
||||
-- a source that failed is not retried by the next slot that wants it.
|
||||
body.dashboardSlots = {}
|
||||
local shelves = {}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
if not requestIsCurrent() then return nil, "cancelled" end
|
||||
local config = DashboardSections.at(self.settings, index)
|
||||
local slot = { type = config.type, smartScopeId = config.params and config.params.smartScopeId }
|
||||
if DashboardSections.isBookSource(config.type) then
|
||||
local signature = DashboardSections.signature(config)
|
||||
local books = shelves[signature]
|
||||
if not books then
|
||||
local section, section_err = fetchSection(config)
|
||||
if section_err == "cancelled" then return nil, section_err end
|
||||
books = section_err and {} or (section and section.books or {})
|
||||
shelves[signature] = books
|
||||
end
|
||||
slot.books = books
|
||||
end
|
||||
body.dashboardSlots[index] = slot
|
||||
end
|
||||
self:cacheDashboard(body)
|
||||
return self:dashboardContext(body)
|
||||
end
|
||||
@@ -259,16 +277,18 @@ function CatalogDashboard:loadDashboardRoot(replace, opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- All books rendered on the dashboard (continue reading + the configurable
|
||||
-- row), used for thumbnail prefetching and cover-cache eviction.
|
||||
-- All books available to dashboard slots, used for thumbnail prefetching and
|
||||
-- cover-cache eviction. Each grid source carries up to twelve books.
|
||||
function CatalogDashboard.dashboardBooks(dashboard)
|
||||
dashboard = dashboard or {}
|
||||
local books = {}
|
||||
for _, book in ipairs(dashboard.continueReading or {}) do
|
||||
table.insert(books, book)
|
||||
end
|
||||
for _, book in ipairs(CatalogDashboard.dashboardSectionBooks(dashboard)) do
|
||||
table.insert(books, book)
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
for _, book in ipairs(CatalogDashboard.dashboardSlotBooks(dashboard, index)) do
|
||||
table.insert(books, book)
|
||||
end
|
||||
end
|
||||
return books
|
||||
end
|
||||
@@ -374,10 +394,10 @@ function CatalogDashboard:dashboardSectionSupportsReroll(config)
|
||||
return (config or {}).type == DashboardSections.DEFAULT_TYPE
|
||||
end
|
||||
|
||||
function CatalogDashboard:buildDashboardRerollButton()
|
||||
function CatalogDashboard:buildDashboardRerollButton(index)
|
||||
local size = Screen:scaleBySize(24)
|
||||
return BookOrbitDashboardIconButton:new{
|
||||
entry = { kind = "dashboard-reroll", icon = "cre.render.reload" },
|
||||
entry = { kind = "dashboard-reroll", section_index = index or 1, icon = "cre.render.reload" },
|
||||
dimen = Geom:new{ x = 0, y = 0, w = size, h = size },
|
||||
menu = self,
|
||||
}
|
||||
@@ -455,10 +475,6 @@ function CatalogDashboard:dashboardHeroSlots(count)
|
||||
return self.content_w >= Screen:scaleBySize(420) and 2 or 1
|
||||
end
|
||||
|
||||
function CatalogDashboard:dashboardTallLayout()
|
||||
return self.inner_dimen.h / math.max(1, self.inner_dimen.w) >= DASHBOARD_TALL_ASPECT_RATIO
|
||||
end
|
||||
|
||||
-- The Continue-reading hero row: full-width hero cards side by side, paged
|
||||
-- through all in-progress books via the chevrons in the section header (the
|
||||
-- e-ink take on a horizontal scroll).
|
||||
@@ -494,7 +510,7 @@ end
|
||||
function CatalogDashboard:sectionRowMetrics(count, gap)
|
||||
local slots = math.min(SECTION_TARGET_SLOTS, count)
|
||||
gap = gap or self.dash_inner_gap
|
||||
local min_card_w = Screen:scaleBySize(72)
|
||||
local min_card_w = Screen:scaleBySize(SHELF_MIN_CARD_WIDTH)
|
||||
while slots > 1
|
||||
and math.floor((self.content_w - (slots - 1) * gap) / slots) < min_card_w do
|
||||
slots = slots - 1
|
||||
@@ -503,6 +519,23 @@ function CatalogDashboard:sectionRowMetrics(count, gap)
|
||||
return slots, card_w, CatalogWidgets.coverCardHeight(card_w, false, false)
|
||||
end
|
||||
|
||||
-- Shelf geometry under a vertical budget. A cover card's height follows from
|
||||
-- its width, so a shelf that is too tall gets more, narrower cards per row
|
||||
-- rather than cards drawn out of aspect. The row can still exceed max_height
|
||||
-- once the cards hit their minimum width; the caller decides what to do then.
|
||||
function CatalogDashboard:shelfRowMetrics(count, gap, max_height)
|
||||
local slots, card_w, row_h = self:sectionRowMetrics(count, gap)
|
||||
if not max_height then return slots, card_w, row_h end
|
||||
local min_card_w = Screen:scaleBySize(SHELF_MIN_CARD_WIDTH)
|
||||
while row_h > max_height do
|
||||
local next_card_w = math.floor((self.content_w - slots * gap) / (slots + 1))
|
||||
if next_card_w < min_card_w then break end
|
||||
slots, card_w = slots + 1, next_card_w
|
||||
row_h = CatalogWidgets.coverCardHeight(card_w, false, false)
|
||||
end
|
||||
return slots, card_w, row_h
|
||||
end
|
||||
|
||||
-- A paged row of cover cards, evenly distributed across the full
|
||||
-- content width. Paging is driven by the chevrons in the section header.
|
||||
function CatalogDashboard:addDashboardCoverRow(
|
||||
@@ -708,240 +741,254 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
|
||||
local old_dimen = self:prepareCustomUpdate(no_recalculate_dimen)
|
||||
self:ensureOnDeviceCurrent()
|
||||
local context = self.current_context or {}
|
||||
local dashboard = context.dashboard
|
||||
local continue_books = dashboard and dashboard.continueReading or {}
|
||||
-- A cached body fetched for a different section is not rendered as this
|
||||
-- one's content; the row waits for the refresh already in flight.
|
||||
local section_pending = context.section_stale == true
|
||||
local section_config = section_pending and self:dashboardConfiguredSection()
|
||||
or self:dashboardBodySection(dashboard)
|
||||
local section_books = section_pending and {} or self.dashboardSectionBooks(dashboard)
|
||||
local dashboard = context.dashboard or {}
|
||||
local continue_books = dashboard.continueReading or {}
|
||||
local action_entries = self:dashboardActionEntries()
|
||||
local summary = self:dashboardStatsSummary() or { today_seconds = 0, week_seconds = 0, streak_days = 0 }
|
||||
local stale_sections = type(context.section_stale) == "table" and context.section_stale or {}
|
||||
|
||||
local function px(n) return Screen:scaleBySize(n) end
|
||||
local avail = self.available_height
|
||||
local inner_gap = px(9)
|
||||
local top_gap = px(4)
|
||||
local stats_gap = px(12)
|
||||
local section_gap = px(18)
|
||||
local section_gap = px(14)
|
||||
local compact_gap = px(SECTION_COMPACT_GAP)
|
||||
self.dash_inner_gap = inner_gap
|
||||
self.dash_used = 0
|
||||
|
||||
local has_continue = #continue_books > 0
|
||||
|
||||
local header_h = CatalogWidgets.buildDashboardSectionHeader("X", self.content_w):getSize().h
|
||||
local browse_row_h = px(42)
|
||||
local browse_cols = 3
|
||||
local browse_rows = 3
|
||||
local hero_h = has_continue and math.min(px(150), math.max(px(100), math.floor(avail * 0.20))) or 0
|
||||
local empty_h = px(72)
|
||||
local stats_widget = summary and self:buildDashboardStatsStrip(summary, dashboard) or nil
|
||||
local stats_h = stats_widget and stats_widget:getSize().h or 0
|
||||
local hero_h = math.min(px(150), math.max(px(76), math.floor(avail * 0.18)))
|
||||
|
||||
local show_stats = stats_widget ~= nil
|
||||
local show_section = #section_books > 0 or section_pending
|
||||
local section_row_gap = inner_gap
|
||||
local section_slots = 0
|
||||
local section_card_w = 0
|
||||
local section_row_h = 0
|
||||
local section_rows = 1
|
||||
|
||||
local function updateSectionMetrics()
|
||||
if show_section and #section_books > 0 then
|
||||
section_slots, section_card_w, section_row_h = self:sectionRowMetrics(#section_books, section_row_gap)
|
||||
else
|
||||
section_slots, section_card_w, section_row_h = 0, 0, 0
|
||||
end
|
||||
end
|
||||
updateSectionMetrics()
|
||||
|
||||
local function sectionGridHeight()
|
||||
if not show_section then return 0 end
|
||||
if section_pending then return empty_h end
|
||||
return section_rows * section_row_h + math.max(0, section_rows - 1) * inner_gap
|
||||
local configs = {}
|
||||
local stats_widgets = {}
|
||||
local hidden = {}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
configs[index] = DashboardSections.at(self.settings, index)
|
||||
end
|
||||
|
||||
-- Fixed blocks are measured up front. If the dashboard gets too tight, the
|
||||
-- stats strip drops first, then the configurable row.
|
||||
local function fixedHeight()
|
||||
local function isShelf(config)
|
||||
return config.type ~= "stats" and config.type ~= "continue-reading" and config.type ~= "browse"
|
||||
end
|
||||
|
||||
-- Slots with a native renderer take the height they need; the shelves share
|
||||
-- whatever is left of the page.
|
||||
local function measureFixed()
|
||||
local fixed = top_gap
|
||||
+ (show_stats and (stats_h + stats_gap) or 0)
|
||||
+ header_h + inner_gap
|
||||
+ (has_continue and hero_h or empty_h)
|
||||
+ section_gap
|
||||
+ (show_section and (header_h + inner_gap + sectionGridHeight() + section_gap) or 0)
|
||||
+ header_h + inner_gap + browse_rows * browse_row_h
|
||||
+ inner_gap
|
||||
return fixed
|
||||
end
|
||||
if show_stats and fixedHeight() > avail then
|
||||
show_stats = false
|
||||
end
|
||||
if show_section and fixedHeight() > avail then
|
||||
show_section = false
|
||||
updateSectionMetrics()
|
||||
end
|
||||
if show_section and not section_pending then
|
||||
local compact_gap = px(SECTION_COMPACT_GAP)
|
||||
if compact_gap < section_row_gap then
|
||||
local previous_gap = section_row_gap
|
||||
local previous_slots, previous_card_w, previous_row_h = section_slots, section_card_w, section_row_h
|
||||
section_row_gap = compact_gap
|
||||
updateSectionMetrics()
|
||||
if fixedHeight() > avail then
|
||||
section_row_gap = previous_gap
|
||||
section_slots, section_card_w, section_row_h = previous_slots, previous_card_w, previous_row_h
|
||||
local shelf_count = 0
|
||||
local max_shelf_books = 0
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
if not hidden[index] then
|
||||
local config = configs[index]
|
||||
if config.type == "stats" then
|
||||
stats_widgets[index] = stats_widgets[index] or self:buildDashboardStatsStrip(summary, dashboard)
|
||||
local stats_widget = stats_widgets[index]
|
||||
fixed = fixed + (stats_widget and stats_widget:getSize().h or px(STATS_MIN_BODY_HEIGHT)) + section_gap
|
||||
elseif config.type == "continue-reading" then
|
||||
fixed = fixed + header_h + inner_gap + hero_h + section_gap
|
||||
elseif config.type == "browse" then
|
||||
fixed = fixed + header_h + inner_gap + browse_rows * browse_row_h + section_gap
|
||||
else
|
||||
shelf_count = shelf_count + 1
|
||||
max_shelf_books = math.max(max_shelf_books, #self.dashboardSlotBooks(dashboard, index))
|
||||
fixed = fixed + header_h + inner_gap + section_gap
|
||||
end
|
||||
end
|
||||
end
|
||||
return fixed, shelf_count, max_shelf_books
|
||||
end
|
||||
if show_section and not section_pending and self:dashboardTallLayout() and section_slots > 0 then
|
||||
local full_rows = math.floor(#section_books / section_slots)
|
||||
local remainder = #section_books % section_slots
|
||||
local book_rows = full_rows
|
||||
if remainder >= math.ceil(section_slots * 0.5) then
|
||||
book_rows = book_rows + 1
|
||||
end
|
||||
local max_rows = math.min(SECTION_MAX_ROWS, math.max(1, book_rows))
|
||||
while section_rows < max_rows do
|
||||
section_rows = section_rows + 1
|
||||
if fixedHeight() > avail then
|
||||
section_rows = section_rows - 1
|
||||
break
|
||||
end
|
||||
|
||||
local fixed_h, shelf_count, max_shelf_books = measureFixed()
|
||||
local shelf_slots, shelf_card_w, shelf_row_h = 0, 0, 0
|
||||
|
||||
local function pageFits()
|
||||
if shelf_count == 0 then
|
||||
shelf_slots, shelf_card_w, shelf_row_h = 0, 0, 0
|
||||
return fixed_h <= avail
|
||||
end
|
||||
shelf_slots, shelf_card_w, shelf_row_h = self:shelfRowMetrics(
|
||||
math.max(1, max_shelf_books), compact_gap,
|
||||
math.floor(math.max(0, avail - fixed_h) / shelf_count))
|
||||
return fixed_h + shelf_count * shelf_row_h <= avail
|
||||
end
|
||||
if show_stats then
|
||||
local extra_stats_h = math.min(px(56), math.max(0, avail - fixedHeight()))
|
||||
if extra_stats_h > 0 then
|
||||
stats_widget = self:buildDashboardStatsStrip(summary, dashboard, stats_h + extra_stats_h)
|
||||
stats_h = stats_widget:getSize().h
|
||||
|
||||
-- Too many slots for the page: the stats strip goes first, since it only
|
||||
-- summarises what the reader can reach elsewhere, then shelves bottom-up.
|
||||
local function slotToDrop()
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
if not hidden[index] and configs[index].type == "stats" then return index end
|
||||
end
|
||||
for index = DashboardSections.SLOT_COUNT, 1, -1 do
|
||||
if not hidden[index] and isShelf(configs[index]) then return index end
|
||||
end
|
||||
end
|
||||
|
||||
local hero_slots = self:dashboardHeroSlots(#continue_books)
|
||||
local hero_pages = has_continue and math.max(1, math.ceil(#continue_books / hero_slots)) or 0
|
||||
local hero_page = has_continue and self:dashboardPage("continue", hero_pages) or 1
|
||||
local continue_controls
|
||||
if hero_pages > 1 then
|
||||
local prev_button, next_button = self:buildDashboardHeaderNav("continue", hero_page, hero_pages)
|
||||
continue_controls = { prev_button, next_button }
|
||||
while not pageFits() do
|
||||
local drop = slotToDrop()
|
||||
if not drop then break end
|
||||
hidden[drop] = true
|
||||
fixed_h, shelf_count, max_shelf_books = measureFixed()
|
||||
end
|
||||
|
||||
self:addDashboardSpacer(top_gap)
|
||||
if show_stats then
|
||||
self:addDashboardInset(stats_widget)
|
||||
self:addDashboardSpacer(stats_gap)
|
||||
local function renderStats(index)
|
||||
self:addDashboardInset(stats_widgets[index])
|
||||
end
|
||||
|
||||
self:addDashboardHeader(_("Continue reading"), continue_controls)
|
||||
self:addDashboardSpacer(inner_gap)
|
||||
if has_continue then
|
||||
self:addDashboardHeroRow(continue_books, hero_h, hero_slots, hero_page)
|
||||
elseif context.loading then
|
||||
self:addDashboardEmptyState(_("Loading dashboard..."))
|
||||
elseif context.unavailable then
|
||||
self:addDashboardEmptyState(
|
||||
_("The dashboard could not be loaded."),
|
||||
_("Retry"),
|
||||
function() self:refreshCurrent() end)
|
||||
else
|
||||
self:addDashboardEmptyState(
|
||||
_("Nothing in progress yet."),
|
||||
_("Browse all books"),
|
||||
function()
|
||||
local function renderContinueReading(index, config)
|
||||
local hero_slots = self:dashboardHeroSlots(#continue_books)
|
||||
local hero_pages = #continue_books > 0 and math.max(1, math.ceil(#continue_books / hero_slots)) or 0
|
||||
local page_id = "continue" .. tostring(index)
|
||||
local hero_page = hero_pages > 0 and self:dashboardPage(page_id, hero_pages) or 1
|
||||
local controls
|
||||
if hero_pages > 1 then
|
||||
local prev_button, next_button = self:buildDashboardHeaderNav(page_id, hero_page, hero_pages)
|
||||
controls = { prev_button, next_button }
|
||||
end
|
||||
self:addDashboardHeader(DashboardSections.headerText(config), controls)
|
||||
self:addDashboardSpacer(inner_gap)
|
||||
if #continue_books > 0 then
|
||||
self:addDashboardHeroRow(continue_books, hero_h, hero_slots, hero_page)
|
||||
elseif context.loading then
|
||||
self:addDashboardEmptyState(_("Loading dashboard..."))
|
||||
elseif context.unavailable then
|
||||
self:addDashboardEmptyState(_("The dashboard could not be loaded."), _("Retry"), function() self:refreshCurrent() end)
|
||||
else
|
||||
self:addDashboardEmptyState(_("Nothing in progress yet."), _("Browse all books"), function()
|
||||
self:onMenuSelect({ text = _("All Books"), kind = "books", params = { sort = "title" } })
|
||||
end)
|
||||
end
|
||||
self:addDashboardSpacer(section_gap)
|
||||
|
||||
if show_section then
|
||||
local section_controls = {}
|
||||
local section_page = 1
|
||||
if not section_pending then
|
||||
section_slots = math.min(section_slots, #section_books)
|
||||
local section_page_size = math.max(1, section_slots * section_rows)
|
||||
local section_pages = math.max(1, math.ceil(#section_books / section_page_size))
|
||||
section_page = self:dashboardPage(SECTION_PAGE_ID, section_pages)
|
||||
if section_pages > 1 then
|
||||
local prev_button, next_button = self:buildDashboardHeaderNav(SECTION_PAGE_ID, section_page, section_pages)
|
||||
table.insert(section_controls, prev_button)
|
||||
table.insert(section_controls, next_button)
|
||||
end
|
||||
if self:dashboardSectionSupportsReroll(section_config) then
|
||||
table.insert(section_controls, self:buildDashboardRerollButton())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self:addDashboardHeader(DashboardSections.headerText(section_config), section_controls)
|
||||
local function renderBrowse(config)
|
||||
self:addDashboardHeader(DashboardSections.headerText(config))
|
||||
self:addDashboardSpacer(inner_gap)
|
||||
if section_pending then
|
||||
self:addDashboardEmptyState(_("Loading..."))
|
||||
else
|
||||
self:addDashboardCoverGrid(SECTION_PAGE_ID, section_books, section_row_h, false, false,
|
||||
section_slots, section_card_w, section_page, section_rows)
|
||||
end
|
||||
self:addDashboardSpacer(section_gap)
|
||||
self:addDashboardBrowseList(action_entries, browse_row_h, browse_cols, browse_rows)
|
||||
end
|
||||
|
||||
self:addDashboardHeader(_("Browse"))
|
||||
self:addDashboardSpacer(inner_gap)
|
||||
self:addDashboardBrowseList(action_entries, browse_row_h, browse_cols, browse_rows)
|
||||
local function renderShelf(index, config)
|
||||
local page_id = SECTION_PAGE_PREFIX .. tostring(index)
|
||||
local pending = stale_sections[index] == true
|
||||
local books = pending and {} or self.dashboardSlotBooks(dashboard, index)
|
||||
local slots = math.max(1, math.min(shelf_slots, #books))
|
||||
local pages = math.max(1, math.ceil(#books / slots))
|
||||
local page = self:dashboardPage(page_id, pages)
|
||||
local controls = {}
|
||||
if pages > 1 then
|
||||
local prev_button, next_button = self:buildDashboardHeaderNav(page_id, page, pages)
|
||||
table.insert(controls, prev_button)
|
||||
table.insert(controls, next_button)
|
||||
end
|
||||
if not pending and self:dashboardSectionSupportsReroll(config) then
|
||||
table.insert(controls, self:buildDashboardRerollButton(index))
|
||||
end
|
||||
self:addDashboardHeader(DashboardSections.headerText(config), controls)
|
||||
self:addDashboardSpacer(inner_gap)
|
||||
if pending or context.loading then
|
||||
self:addDashboardEmptyState(_("Loading..."))
|
||||
elseif #books == 0 then
|
||||
self:addDashboardEmptyState(_("No books found."))
|
||||
else
|
||||
self:addDashboardCoverGrid(page_id, books, shelf_row_h, false, false, slots, shelf_card_w, page, 1)
|
||||
end
|
||||
end
|
||||
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
if not hidden[index] then
|
||||
local config = configs[index]
|
||||
if config.type == "stats" then
|
||||
renderStats(index)
|
||||
elseif config.type == "continue-reading" then
|
||||
renderContinueReading(index, config)
|
||||
elseif config.type == "browse" then
|
||||
renderBrowse(config)
|
||||
else
|
||||
renderShelf(index, config)
|
||||
end
|
||||
self:addDashboardSpacer(section_gap)
|
||||
end
|
||||
end
|
||||
|
||||
self:addDashboardSpacer(math.max(0, avail - self.dash_used))
|
||||
|
||||
self:finishCustomUpdate(old_dimen, select_number)
|
||||
end
|
||||
|
||||
-- Replaces the configurable row's books in place, leaving the rest of the page
|
||||
-- Replaces one grid slot's books in place, leaving the rest of the dashboard
|
||||
-- and its cache entry alone.
|
||||
function CatalogDashboard:applyDashboardSectionBooks(context, books)
|
||||
function CatalogDashboard:applyDashboardSectionBooks(context, books, index)
|
||||
index = index or 1
|
||||
local dashboard = context and context.dashboard
|
||||
if not dashboard then return end
|
||||
if type(dashboard.section) == "table" then
|
||||
dashboard.section.books = books
|
||||
else
|
||||
dashboard.discover = books
|
||||
end
|
||||
dashboard.dashboardSlots = dashboard.dashboardSlots or {}
|
||||
local slot = dashboard.dashboardSlots[index] or { type = DashboardSections.at(self.settings, index).type }
|
||||
slot.books = books
|
||||
dashboard.dashboardSlots[index] = slot
|
||||
context.dash_pages = context.dash_pages or {}
|
||||
context.dash_pages[SECTION_PAGE_ID] = 1
|
||||
context.section_stale = false
|
||||
context.dash_pages[SECTION_PAGE_PREFIX .. tostring(index)] = 1
|
||||
context.section_stale = type(context.section_stale) == "table" and context.section_stale or {}
|
||||
context.section_stale[index] = nil
|
||||
self:cacheDashboard(dashboard)
|
||||
self:scheduleThumbnailDownloads(self.dashboardBooks(dashboard))
|
||||
self:updateItems()
|
||||
end
|
||||
|
||||
-- Fetches a fresh set of random Discover books and swaps them into the current
|
||||
-- dashboard without reloading the rest of the page. Only reachable while the
|
||||
-- configurable row is showing Discover.
|
||||
function CatalogDashboard:rerollDiscover()
|
||||
function CatalogDashboard:rerollDiscover(index)
|
||||
if not self:dashboardMode() then return end
|
||||
index = index or 1
|
||||
self:runConnected(function()
|
||||
local body, err = self:fetch(_("Finding books..."), function()
|
||||
return self.client:catalogDiscover()
|
||||
end)
|
||||
if body and body.discover then
|
||||
self:applyDashboardSectionBooks(self.current_context, body.discover)
|
||||
self:applyDashboardSectionBooks(self.current_context, body.discover, index)
|
||||
elseif err and err ~= "cancelled" then
|
||||
self:showServerError(err)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Persists a new choice for the configurable row and reloads the dashboard so
|
||||
-- the row is fetched for it. Called from the picker.
|
||||
function CatalogDashboard:setDashboardSection(config)
|
||||
-- Marks one slot as waiting for the refresh in flight, so books fetched for the
|
||||
-- previous choice are never presented as the new one.
|
||||
local function markSlotPending(context, index)
|
||||
context.section_stale = type(context.section_stale) == "table" and context.section_stale or {}
|
||||
context.section_stale[index] = true
|
||||
context.dash_pages = context.dash_pages or {}
|
||||
context.dash_pages[SECTION_PAGE_PREFIX .. tostring(index)] = 1
|
||||
end
|
||||
|
||||
function CatalogDashboard:setDashboardSection(config, index)
|
||||
index = index or 1
|
||||
local normalized = DashboardSections.normalizeEntry(config)
|
||||
if DashboardSections.signature(normalized) == DashboardSections.signature(self:dashboardConfiguredSection()) then
|
||||
if DashboardSections.signature(normalized) == DashboardSections.signature(DashboardSections.at(self.settings, index)) then
|
||||
return
|
||||
end
|
||||
self:persistSetting(DashboardSections.SETTING_KEY, DashboardSections.store(normalized))
|
||||
self:persistSetting(DashboardSections.SETTING_KEY, DashboardSections.storeAt(self.settings, index, normalized))
|
||||
if not self:dashboardMode() then return end
|
||||
local context = self.current_context
|
||||
if context then
|
||||
context.section_stale = true
|
||||
context.dash_pages = context.dash_pages or {}
|
||||
context.dash_pages[SECTION_PAGE_ID] = 1
|
||||
markSlotPending(context, index)
|
||||
self:updateItems()
|
||||
end
|
||||
self:refreshCurrent()
|
||||
end
|
||||
|
||||
-- Applies a whole slot configuration at once (the settings menu's reset), so
|
||||
-- the dashboard refreshes once rather than once per changed slot.
|
||||
function CatalogDashboard:setDashboardSections(sections)
|
||||
local normalized = DashboardSections.normalize(sections)
|
||||
if DashboardSections.settingsSignature({ [DashboardSections.SETTING_KEY] = normalized })
|
||||
== DashboardSections.settingsSignature(self.settings) then
|
||||
return
|
||||
end
|
||||
self:persistSetting(DashboardSections.SETTING_KEY, normalized)
|
||||
if not self:dashboardMode() then return end
|
||||
local context = self.current_context
|
||||
if context then
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
markSlotPending(context, index)
|
||||
end
|
||||
self:updateItems()
|
||||
end
|
||||
self:refreshCurrent()
|
||||
|
||||
@@ -64,6 +64,13 @@ local function readStatusBadgeIcon(book)
|
||||
return READ_STATUS_BADGE_ICONS[status] or "dogear.reading"
|
||||
end
|
||||
|
||||
-- The dogear assets are drawn as a top-left page fold, and the badge sits in
|
||||
-- the cover's top-right corner, so those need a quarter turn. The rest of the
|
||||
-- set are upright glyphs that must not be turned with them.
|
||||
local function readStatusBadgeRotation(icon)
|
||||
return icon:match("^dogear%.") and 270 or 0
|
||||
end
|
||||
|
||||
local function rowFontSize(nominal, max_size, row_h)
|
||||
local size = math.floor(nominal * row_h * (1 / 64) / SCALE_BY_SIZE)
|
||||
if max_size and size > max_size then return max_size end
|
||||
@@ -208,7 +215,7 @@ function CatalogWidgets.buildReadStatusBadge(book, max_width)
|
||||
local size = math.max(Screen:scaleBySize(12), math.min(max_width, Screen:scaleBySize(20)))
|
||||
return IconWidget:new{
|
||||
icon = icon,
|
||||
rotation_angle = 270,
|
||||
rotation_angle = readStatusBadgeRotation(icon),
|
||||
width = size,
|
||||
height = size,
|
||||
}
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
--[[--
|
||||
Registry for the configurable dashboard row below Continue reading.
|
||||
Registry for the four configurable dashboard slots.
|
||||
|
||||
Every source renders as the same row of cover cards, so a section is fully
|
||||
described by its type plus, for a SmartScope, which scope to run. The stored
|
||||
value is an ordered list even though only the first entry is rendered today:
|
||||
that way a later release can show more than one configurable row without
|
||||
rewriting settings already on the device.
|
||||
Each source keeps its native renderer: Stats is a strip, Continue reading is a
|
||||
hero row, book sources are cover grids, and Browse is the compact action list.
|
||||
A book source is fully described by its type plus, for the sources picked
|
||||
through a catalog list, the filter that was selected. The stored value is an
|
||||
ordered list matching the four dashboard slots.
|
||||
|
||||
The SmartScope name is cached alongside the id purely so the settings menu can
|
||||
label the row without a request. It is never sent to the server.
|
||||
The selected entry's name is cached alongside its filter purely so the settings
|
||||
menu and the section header can label the slot without a request.
|
||||
]]
|
||||
|
||||
local _ = require("gettext")
|
||||
|
||||
local DashboardSections = {}
|
||||
|
||||
DashboardSections.SETTING_KEY = "catalog_dashboard_sections"
|
||||
DashboardSections.CAPABILITY = "catalogDashboardSections"
|
||||
DashboardSections.DEFAULT_TYPE = "random"
|
||||
local PARAM_KEYS = { "libraryId", "collectionId", "smartScopeId", "author", "seriesId", "series", "sort" }
|
||||
|
||||
-- Order here is the order the picker lists them in.
|
||||
DashboardSections.SETTING_KEY = "catalog_dashboard_sections"
|
||||
DashboardSections.DEFAULT_TYPE = "random"
|
||||
DashboardSections.SCHEMA_VERSION = 2
|
||||
DashboardSections.LEGACY_SCHEMA_VERSION = 1
|
||||
DashboardSections.SLOT_COUNT = 4
|
||||
DashboardSections.DEFAULT_SLOTS = {
|
||||
{ type = "stats" },
|
||||
{ type = "continue-reading" },
|
||||
{ type = "random" },
|
||||
{ type = "browse" },
|
||||
}
|
||||
|
||||
-- Order here mirrors the destinations exposed by the dashboard Browse block.
|
||||
DashboardSections.TYPES = {
|
||||
"stats",
|
||||
"continue-reading",
|
||||
"random",
|
||||
"browse",
|
||||
"recently-added",
|
||||
"want-to-read",
|
||||
"up-next-in-series",
|
||||
"smart-scope",
|
||||
"in-progress",
|
||||
"libraries",
|
||||
"authors",
|
||||
"series",
|
||||
"collections",
|
||||
"smart-scopes",
|
||||
}
|
||||
|
||||
local LABELS = {
|
||||
["stats"] = function() return _("Stats") end,
|
||||
["continue-reading"] = function() return _("Continue reading") end,
|
||||
["random"] = function() return _("Discover") end,
|
||||
["browse"] = function() return _("Browse") end,
|
||||
["recently-added"] = function() return _("Recently added") end,
|
||||
["want-to-read"] = function() return _("Want to read") end,
|
||||
["up-next-in-series"] = function() return _("Up next in series") end,
|
||||
["smart-scope"] = function() return _("SmartScope") end,
|
||||
["in-progress"] = function() return _("In progress") end,
|
||||
["libraries"] = function() return _("Libraries") end,
|
||||
["authors"] = function() return _("Authors") end,
|
||||
["series"] = function() return _("Series") end,
|
||||
["collections"] = function() return _("Collections") end,
|
||||
["smart-scopes"] = function() return _("SmartScopes") end,
|
||||
}
|
||||
|
||||
local HELP_TEXT = {
|
||||
["stats"] = function() return _("Your reading summary shown as a compact horizontal strip.") end,
|
||||
["continue-reading"] = function() return _("Books currently in progress, shown as the dashboard hero row.") end,
|
||||
["random"] = function() return _("A random selection from your whole library, reshuffled with the button in the section header.") end,
|
||||
["browse"] = function() return _("Dashboard shortcuts for libraries, collections, SmartScopes, authors, series, and books.") end,
|
||||
["recently-added"] = function() return _("The books most recently added to your library.") end,
|
||||
["want-to-read"] = function() return _("Books you marked as want to read.") end,
|
||||
["up-next-in-series"] = function() return _("The next unread book in each series you have already started.") end,
|
||||
["smart-scope"] = function() return _("Books matching a SmartScope you saved in BookOrbit.") end,
|
||||
["in-progress"] = function() return _("The catalog view for books currently in progress.") end,
|
||||
["libraries"] = function() return _("The existing Libraries catalog, including its normal navigation and back button.") end,
|
||||
["authors"] = function() return _("The existing Authors catalog, including its normal pagination and back button.") end,
|
||||
["series"] = function() return _("The existing Series catalog, including its normal pagination and back button.") end,
|
||||
["collections"] = function() return _("The existing Collections catalog and its normal back navigation.") end,
|
||||
["smart-scopes"] = function() return _("The existing SmartScopes catalog. Choose a scope there rather than configuring one directly on the dashboard.") end,
|
||||
}
|
||||
|
||||
function DashboardSections.isValid(section_type)
|
||||
@@ -58,70 +86,127 @@ function DashboardSections.helpText(section_type)
|
||||
return help and help() or nil
|
||||
end
|
||||
|
||||
-- What the section header shows. A SmartScope row is named after the scope
|
||||
-- itself; the generic label would tell the reader nothing.
|
||||
local BOOK_SOURCES = {
|
||||
["random"] = true,
|
||||
["recently-added"] = true,
|
||||
["in-progress"] = true,
|
||||
["libraries"] = true,
|
||||
["authors"] = true,
|
||||
["series"] = true,
|
||||
["collections"] = true,
|
||||
["smart-scopes"] = true,
|
||||
}
|
||||
|
||||
local CATALOG_SELECTORS = {
|
||||
["libraries"] = true,
|
||||
["authors"] = true,
|
||||
["series"] = true,
|
||||
["collections"] = true,
|
||||
["smart-scopes"] = true,
|
||||
}
|
||||
|
||||
function DashboardSections.isBookSource(section_type)
|
||||
return BOOK_SOURCES[section_type] == true
|
||||
end
|
||||
|
||||
function DashboardSections.isCatalogSelector(section_type)
|
||||
return CATALOG_SELECTORS[section_type] == true
|
||||
end
|
||||
|
||||
function DashboardSections.headerText(config)
|
||||
config = config or {}
|
||||
if config.type == "smart-scope" and type(config.smartScopeName) == "string" and config.smartScopeName ~= "" then
|
||||
return config.smartScopeName
|
||||
if config and type(config.sourceName) == "string" and config.sourceName ~= "" then
|
||||
return config.sourceName
|
||||
end
|
||||
return DashboardSections.label(config.type)
|
||||
return DashboardSections.label(config and config.type)
|
||||
end
|
||||
|
||||
function DashboardSections.defaultConfig()
|
||||
return { type = DashboardSections.DEFAULT_TYPE }
|
||||
function DashboardSections.defaultConfig(index)
|
||||
local config = (index and DashboardSections.DEFAULT_SLOTS[index]) or { type = DashboardSections.DEFAULT_TYPE }
|
||||
return { type = config.type }
|
||||
end
|
||||
|
||||
-- An unusable entry degrades to Discover rather than being dropped: the slot
|
||||
-- always renders something, so a config written by a newer plugin, or a
|
||||
-- SmartScope deleted on the server, still leaves a working dashboard.
|
||||
-- An unusable or obsolete entry degrades to Discover rather than being dropped.
|
||||
function DashboardSections.normalizeEntry(value)
|
||||
if type(value) ~= "table" or not DashboardSections.isValid(value.type) then
|
||||
return DashboardSections.defaultConfig()
|
||||
end
|
||||
if value.type ~= "smart-scope" then
|
||||
return { type = value.type }
|
||||
end
|
||||
local scope_id = tonumber(value.smartScopeId)
|
||||
if not scope_id or scope_id <= 0 then
|
||||
return DashboardSections.defaultConfig()
|
||||
end
|
||||
local entry = { type = "smart-scope", smartScopeId = math.floor(scope_id) }
|
||||
if type(value.smartScopeName) == "string" and value.smartScopeName ~= "" then
|
||||
entry.smartScopeName = value.smartScopeName
|
||||
local entry = { type = value.type }
|
||||
if DashboardSections.isCatalogSelector(value.type) then
|
||||
if type(value.sourceName) ~= "string" or value.sourceName == "" or type(value.params) ~= "table" then
|
||||
return DashboardSections.defaultConfig()
|
||||
end
|
||||
entry.sourceName = value.sourceName
|
||||
entry.params = {}
|
||||
for _, key in ipairs(PARAM_KEYS) do
|
||||
if value.params[key] ~= nil then entry.params[key] = value.params[key] end
|
||||
end
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
function DashboardSections.normalize(value)
|
||||
local normalized = { schemaVersion = DashboardSections.SCHEMA_VERSION }
|
||||
if type(value) ~= "table" or #value == 0 then
|
||||
return { DashboardSections.defaultConfig() }
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
normalized[index] = DashboardSections.defaultConfig(index)
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
local normalized = {}
|
||||
for _index, entry in ipairs(value) do
|
||||
table.insert(normalized, DashboardSections.normalizeEntry(entry))
|
||||
|
||||
-- Releases before the four-slot dashboard stored the one or two configurable
|
||||
-- rows that sat below Continue reading, and wrote no schema marker at all.
|
||||
-- An untagged list is therefore that older shape, not a short current one:
|
||||
-- reading it positionally would drop Stats and duplicate Discover.
|
||||
if value.schemaVersion == nil or value.schemaVersion == DashboardSections.LEGACY_SCHEMA_VERSION then
|
||||
normalized[1] = DashboardSections.defaultConfig(1)
|
||||
normalized[2] = DashboardSections.defaultConfig(2)
|
||||
normalized[3] = DashboardSections.normalizeEntry(value[1])
|
||||
normalized[4] = value[2] and DashboardSections.normalizeEntry(value[2]) or DashboardSections.defaultConfig(4)
|
||||
return normalized
|
||||
end
|
||||
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
normalized[index] = value[index] and DashboardSections.normalizeEntry(value[index]) or DashboardSections.defaultConfig(index)
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
-- The one section rendered today.
|
||||
function DashboardSections.primary(settings)
|
||||
function DashboardSections.at(settings, index)
|
||||
index = index or 1
|
||||
local stored = settings and settings[DashboardSections.SETTING_KEY]
|
||||
return DashboardSections.normalize(stored)[1]
|
||||
local sections = DashboardSections.normalize(stored)
|
||||
return sections[index] or DashboardSections.defaultConfig(index)
|
||||
end
|
||||
|
||||
function DashboardSections.store(config)
|
||||
return { DashboardSections.normalizeEntry(config) }
|
||||
function DashboardSections.storeAt(settings, index, config)
|
||||
index = math.max(1, math.min(DashboardSections.SLOT_COUNT, tonumber(index) or 1))
|
||||
local stored = settings and settings[DashboardSections.SETTING_KEY]
|
||||
local sections = DashboardSections.normalize(stored)
|
||||
sections[index] = DashboardSections.normalizeEntry(config)
|
||||
sections.schemaVersion = DashboardSections.SCHEMA_VERSION
|
||||
return sections
|
||||
end
|
||||
|
||||
-- Identifies which section a cached dashboard body was fetched for, so a body
|
||||
-- cached under a different choice is not mistaken for the current one.
|
||||
function DashboardSections.signature(config)
|
||||
config = DashboardSections.normalizeEntry(config)
|
||||
if config.type == "smart-scope" then
|
||||
return "smart-scope:" .. tostring(config.smartScopeId)
|
||||
if DashboardSections.isCatalogSelector(config.type) then
|
||||
local parts = { config.type }
|
||||
for _, key in ipairs(PARAM_KEYS) do
|
||||
if config.params[key] ~= nil then table.insert(parts, key .. "=" .. tostring(config.params[key])) end
|
||||
end
|
||||
return table.concat(parts, ":")
|
||||
end
|
||||
return config.type
|
||||
end
|
||||
|
||||
function DashboardSections.settingsSignature(settings)
|
||||
local parts = {}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
parts[index] = DashboardSections.signature(DashboardSections.at(settings, index))
|
||||
end
|
||||
return table.concat(parts, "|")
|
||||
end
|
||||
|
||||
return DashboardSections
|
||||
|
||||
@@ -289,107 +289,80 @@ function MainMenu:testConnection()
|
||||
end)
|
||||
end
|
||||
|
||||
function MainMenu:dashboardSectionLabel()
|
||||
return DashboardSections.headerText(DashboardSections.primary(self.settings))
|
||||
function MainMenu:dashboardSectionLabel(index)
|
||||
return DashboardSections.headerText(DashboardSections.at(self.settings, index))
|
||||
end
|
||||
|
||||
-- Applies a new choice for the configurable dashboard row. When the dashboard
|
||||
-- is open it owns the setting write, so the row and its cache signature move
|
||||
-- together; otherwise the setting is written directly.
|
||||
function MainMenu:applyDashboardSection(config, catalog, touchmenu_instance)
|
||||
-- Applies a new choice for one configurable dashboard row.
|
||||
function MainMenu:applyDashboardSection(index, config, catalog, touchmenu_instance)
|
||||
if catalog and catalog.setDashboardSection then
|
||||
catalog:setDashboardSection(config)
|
||||
catalog:setDashboardSection(config, index)
|
||||
else
|
||||
self.settings[DashboardSections.SETTING_KEY] = DashboardSections.store(config)
|
||||
self.settings[DashboardSections.SETTING_KEY] = DashboardSections.storeAt(self.settings, index, config)
|
||||
G_reader_settings:flush()
|
||||
end
|
||||
if touchmenu_instance then touchmenu_instance:updateItems() end
|
||||
end
|
||||
|
||||
function MainMenu:dashboardSectionClient(catalog)
|
||||
if catalog and catalog.client then return catalog.client end
|
||||
return BookOrbitApi.new(self:apiOpts())
|
||||
-- Restores the four default slots in one write, so the dashboard refreshes once
|
||||
-- rather than once per slot. The catalog owns the write while it is open, which
|
||||
-- keeps the slots and the cache signature in step.
|
||||
function MainMenu:resetDashboardSections(catalog, touchmenu_instance)
|
||||
local defaults = DashboardSections.normalize(nil)
|
||||
if catalog and catalog.setDashboardSections then
|
||||
catalog:setDashboardSections(defaults)
|
||||
else
|
||||
self.settings[DashboardSections.SETTING_KEY] = defaults
|
||||
G_reader_settings:flush()
|
||||
end
|
||||
if touchmenu_instance then touchmenu_instance:updateItems() end
|
||||
end
|
||||
|
||||
-- SmartScopes live on the server, so the chooser has to ask for them. The
|
||||
-- chosen name is stored beside the id purely so the row can be labelled later
|
||||
-- without another request.
|
||||
function MainMenu:chooseDashboardSmartScope(catalog, touchmenu_instance)
|
||||
local ButtonDialog = require("ui/widget/buttondialog")
|
||||
if NetworkMgr:willRerunWhenConnected(function() self:chooseDashboardSmartScope(catalog, touchmenu_instance) end) then
|
||||
function MainMenu:chooseDashboardCatalogSource(index, section, catalog, touchmenu_instance)
|
||||
if self.dashboard_menu_container then
|
||||
local menu_container = self.dashboard_menu_container
|
||||
self.dashboard_menu_container = nil
|
||||
UIManager:close(menu_container)
|
||||
elseif touchmenu_instance then
|
||||
touchmenu_instance:closeMenu()
|
||||
end
|
||||
|
||||
local function openSelector(target)
|
||||
target:loadSection(section, { dashboard_source_index = index })
|
||||
end
|
||||
|
||||
if catalog then
|
||||
UIManager:nextTick(function() openSelector(catalog) end)
|
||||
return
|
||||
end
|
||||
|
||||
Device:setIgnoreInput(true)
|
||||
local body, err = self:dashboardSectionClient(catalog):catalogSection("smart-scopes")
|
||||
Device:setIgnoreInput(false)
|
||||
|
||||
local scopes = body and body.items or nil
|
||||
if not scopes then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Could not load your SmartScopes: %1"), tostring(err)),
|
||||
timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
if #scopes == 0 then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("You have no SmartScopes yet. Create one in BookOrbit web settings."),
|
||||
timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local current = DashboardSections.primary(self.settings)
|
||||
local dialog
|
||||
local buttons = {}
|
||||
for _index, scope in ipairs(scopes) do
|
||||
local scope_id = tonumber(scope.id)
|
||||
if scope_id then
|
||||
table.insert(buttons, {
|
||||
{
|
||||
text = scope.title .. (current.smartScopeId == scope_id and " *" or ""),
|
||||
callback = function()
|
||||
UIManager:close(dialog)
|
||||
self:applyDashboardSection(
|
||||
{ type = "smart-scope", smartScopeId = scope_id, smartScopeName = scope.title },
|
||||
catalog, touchmenu_instance)
|
||||
end,
|
||||
},
|
||||
})
|
||||
self:openCatalogBrowser(false)
|
||||
UIManager:nextTick(function()
|
||||
if self.catalog_browser then
|
||||
openSelector(self.catalog_browser)
|
||||
else
|
||||
UIManager:show(InfoMessage:new{ text = _("Could not open the BookOrbit catalog."), timeout = 3 })
|
||||
end
|
||||
end
|
||||
dialog = ButtonDialog:new{
|
||||
title = _("Show which SmartScope?"),
|
||||
buttons = buttons,
|
||||
}
|
||||
UIManager:show(dialog)
|
||||
end)
|
||||
end
|
||||
|
||||
function MainMenu:dashboardSectionItems(catalog)
|
||||
function MainMenu:dashboardSectionItems(index, catalog)
|
||||
local items = {}
|
||||
for _index, section_type in ipairs(DashboardSections.TYPES) do
|
||||
local is_smart_scope = section_type == "smart-scope"
|
||||
local is_selector = DashboardSections.isCatalogSelector(section_type)
|
||||
table.insert(items, {
|
||||
text_func = function()
|
||||
if is_smart_scope then
|
||||
local current = DashboardSections.primary(self.settings)
|
||||
if current.type == "smart-scope" and current.smartScopeName then
|
||||
return T(_("SmartScope (%1)"), current.smartScopeName)
|
||||
end
|
||||
end
|
||||
return DashboardSections.label(section_type)
|
||||
end,
|
||||
text = DashboardSections.label(section_type),
|
||||
mandatory = is_selector and ">" or nil,
|
||||
help_text = DashboardSections.helpText(section_type),
|
||||
checked_func = function()
|
||||
return DashboardSections.primary(self.settings).type == section_type
|
||||
checked_func = is_selector and nil or function()
|
||||
return DashboardSections.at(self.settings, index).type == section_type
|
||||
end,
|
||||
keep_menu_open = is_smart_scope,
|
||||
keep_menu_open = is_selector,
|
||||
callback = function(touchmenu_instance)
|
||||
if is_smart_scope then
|
||||
self:chooseDashboardSmartScope(catalog, touchmenu_instance)
|
||||
if is_selector then
|
||||
self:chooseDashboardCatalogSource(index, section_type, catalog, touchmenu_instance)
|
||||
else
|
||||
self:applyDashboardSection({ type = section_type }, catalog, touchmenu_instance)
|
||||
self:applyDashboardSection(index, { type = section_type }, catalog, touchmenu_instance)
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -398,20 +371,30 @@ function MainMenu:dashboardSectionItems(catalog)
|
||||
end
|
||||
|
||||
function MainMenu:dashboardSettingsMenu(catalog)
|
||||
return {
|
||||
local items = {
|
||||
{
|
||||
text_func = function()
|
||||
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
|
||||
end,
|
||||
sub_item_table = self:catalogAutoOpenMenu(),
|
||||
},
|
||||
{
|
||||
text_func = function()
|
||||
return T(_("Show below Continue reading (%1)"), self:dashboardSectionLabel())
|
||||
end,
|
||||
sub_item_table = self:dashboardSectionItems(catalog),
|
||||
},
|
||||
}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
table.insert(items, {
|
||||
text_func = function()
|
||||
return T(_("Section %1 (%2)"), index, self:dashboardSectionLabel(index))
|
||||
end,
|
||||
sub_item_table = self:dashboardSectionItems(index, catalog),
|
||||
})
|
||||
end
|
||||
table.insert(items, {
|
||||
text = _("Reset to Default"),
|
||||
separator = true,
|
||||
callback = function(touchmenu_instance)
|
||||
self:resetDashboardSections(catalog, touchmenu_instance)
|
||||
end,
|
||||
})
|
||||
return items
|
||||
end
|
||||
|
||||
function MainMenu:syncSettingsMenu(has_open_book)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Proves the configurable dashboard row: how a stored choice degrades, when the
|
||||
-- section parameter is put on the wire, and how a cached body fetched for a
|
||||
-- different choice is treated.
|
||||
-- Proves the configurable dashboard slots: what each shelf costs in requests,
|
||||
-- how a failing or repeated source is handled, how shelves are measured, and
|
||||
-- how a cached body fetched for a different configuration is treated.
|
||||
|
||||
local connected = true
|
||||
|
||||
@@ -77,11 +77,18 @@ package.loaded["ui/uimanager"] = {
|
||||
package.loaded["ui/widget/verticalgroup"] = widgetClass()
|
||||
package.loaded["ui/widget/verticalspan"] = widgetClass()
|
||||
package.loaded["bookorbit_stats_reader"] = {}
|
||||
package.loaded["bookorbit_catalog_widgets"] = {}
|
||||
package.loaded["bookorbit_catalog_widgets"] = {
|
||||
DashboardCoverCard = widgetClass(),
|
||||
DashboardHeroCard = widgetClass(),
|
||||
DashboardBrowseRow = widgetClass(),
|
||||
DashboardIconButton = widgetClass(),
|
||||
coverCardHeight = function(card_w)
|
||||
return card_w + 20
|
||||
end,
|
||||
}
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||
|
||||
local Capabilities = require("bookorbit_capabilities")
|
||||
local CatalogDashboard = require("bookorbit_catalog_dashboard")
|
||||
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||
|
||||
@@ -91,57 +98,50 @@ local function assertEqual(actual, expected, label)
|
||||
end
|
||||
end
|
||||
|
||||
-- A stored choice that cannot be honoured falls back to Discover rather than
|
||||
-- leaving the row undefined.
|
||||
assertEqual(DashboardSections.normalizeEntry(nil).type, "random", "a missing entry degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "not-a-source" }).type, "random", "an unknown type degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "smart-scope" }).type, "random", "a scope without an id degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "want-to-read", "a known type is kept")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "smart-scope", smartScopeId = "4" }).smartScopeId, 4, "a numeric scope id is coerced")
|
||||
|
||||
assertEqual(DashboardSections.signature({ type = "recently-added" }), "recently-added", "a plain type signs as itself")
|
||||
assertEqual(DashboardSections.signature({ type = "smart-scope", smartScopeId = 4 }), "smart-scope:4", "a scope signs with its id")
|
||||
assertEqual(DashboardSections.signature({ type = "smart-scope", smartScopeId = 5 })
|
||||
~= DashboardSections.signature({ type = "smart-scope", smartScopeId = 4 }), true, "two scopes sign differently")
|
||||
|
||||
assertEqual(DashboardSections.headerText({ type = "want-to-read" }), "Want to read", "a plain row is titled by its source")
|
||||
assertEqual(DashboardSections.headerText({ type = "smart-scope", smartScopeId = 4, smartScopeName = "Sci-fi" }), "Sci-fi",
|
||||
"a scope row is titled by the scope")
|
||||
local author_source = { type = "authors", sourceName = "Ursula K. Le Guin", params = { author = "Ursula K. Le Guin", sort = "title" } }
|
||||
|
||||
local requests
|
||||
local last_section
|
||||
local last_books_params
|
||||
|
||||
local function newCatalog(opts)
|
||||
opts = opts or {}
|
||||
Capabilities.reset()
|
||||
requests = {}
|
||||
last_section = nil
|
||||
last_books_params = nil
|
||||
local stored_sections = opts.sections
|
||||
if opts.section then
|
||||
stored_sections = { opts.section, schemaVersion = DashboardSections.LEGACY_SCHEMA_VERSION }
|
||||
end
|
||||
local catalog = {
|
||||
title = "BookOrbit",
|
||||
settings = {
|
||||
catalog_dashboard_cache = opts.cache,
|
||||
catalog_dashboard_cache_section = opts.cache_section,
|
||||
[DashboardSections.SETTING_KEY] = opts.section and { opts.section } or nil,
|
||||
[DashboardSections.SETTING_KEY] = stored_sections,
|
||||
},
|
||||
client = {
|
||||
server_url = opts.server_url or "https://example.test",
|
||||
username = "reader",
|
||||
getPluginVersion = function()
|
||||
table.insert(requests, "version")
|
||||
if opts.capability_error then return nil, opts.capability_error end
|
||||
return { capabilities = opts.capabilities or {} }
|
||||
end,
|
||||
catalogDashboard = function(_, section)
|
||||
table.insert(requests, "dashboard")
|
||||
last_section = section
|
||||
if section and opts.reject_section then return nil, opts.reject_section end
|
||||
local body = { continueReading = {}, discover = {} }
|
||||
if section then
|
||||
body.section = { type = section.type, smartScopeId = section.smartScopeId, books = { { id = 9 } } }
|
||||
body.discover = {}
|
||||
end
|
||||
assertEqual(section, nil, "the dashboard body is requested without a section parameter")
|
||||
local body = { continueReading = {} }
|
||||
if not opts.omit_discover then body.discover = {} end
|
||||
return body
|
||||
end,
|
||||
catalogBooks = function(_, params)
|
||||
table.insert(requests, "books:" .. tostring(params.sort))
|
||||
last_books_params = params
|
||||
if opts.catalog_books_error and opts.catalog_books_error[params.sort] then
|
||||
return nil, opts.catalog_books_error[params.sort]
|
||||
end
|
||||
local ids = { recently_added = 11, recently_read = 12, title = 13 }
|
||||
return { items = { { id = ids[params.sort] or 9 } }, page = 1, size = params.size, total = 1 }
|
||||
end,
|
||||
catalogDiscover = function()
|
||||
table.insert(requests, "discover")
|
||||
if opts.discover_error then return nil, opts.discover_error end
|
||||
return { discover = { { id = 14 } } }
|
||||
end,
|
||||
},
|
||||
}
|
||||
for name, fn in pairs(CatalogDashboard) do
|
||||
@@ -156,77 +156,146 @@ local function newCatalog(opts)
|
||||
return catalog
|
||||
end
|
||||
|
||||
-- Discover is the default, so the common case never probes for the capability
|
||||
-- and never puts the parameter on the wire.
|
||||
-- The default dashboard is one request: Discover is served from the body that
|
||||
-- was already fetched, and no capability is probed.
|
||||
local catalog = newCatalog{}
|
||||
assertEqual(catalog:dashboardSectionRequest(), nil, "the default row sends no section parameter")
|
||||
assertEqual(#requests, 0, "the default row does not probe capabilities")
|
||||
catalog:dashboardRoot()
|
||||
assertEqual(#requests, 1, "the default dashboard costs a single request")
|
||||
assertEqual(requests[1], "dashboard", "the default dashboard only fetches the dashboard body")
|
||||
|
||||
-- A non-default choice is only sent to a server that advertises it.
|
||||
catalog = newCatalog{ section = { type = "want-to-read" }, capabilities = { "catalogDashboardSections" } }
|
||||
assertEqual(catalog:dashboardSectionRequest().type, "want-to-read", "an advertised capability sends the section")
|
||||
-- Grid books are stored by slot.
|
||||
assertEqual(CatalogDashboard.dashboardSlotBooks({ dashboardSlots = { [3] = { books = { { id = 2 } } } } }, 3)[1].id, 2,
|
||||
"a grid slot exposes its own books")
|
||||
assertEqual(#CatalogDashboard.dashboardSlotBooks(nil, 3), 0, "no body yields no books")
|
||||
|
||||
catalog = newCatalog{ section = { type = "want-to-read" }, capabilities = {} }
|
||||
assertEqual(catalog:dashboardSectionRequest(), nil, "a server without the capability keeps the legacy request")
|
||||
-- Grid geometry is computed from the measured content width and native slot
|
||||
-- count rather than from source-text constants.
|
||||
catalog = newCatalog{}
|
||||
catalog.content_w = 500
|
||||
catalog.dash_inner_gap = 9
|
||||
local slots, card_w, grid_h = catalog:sectionRowMetrics(12, 6)
|
||||
assertEqual(slots, 5, "a wide dashboard budgets five cover slots")
|
||||
assertEqual(card_w, 95, "cover width subtracts only the four inter-card gaps")
|
||||
assertEqual(grid_h, 115, "grid height follows the measured cover-card height")
|
||||
|
||||
-- An unknown answer is not treated as a downgrade, but it still cannot select
|
||||
-- the new route for this request.
|
||||
catalog = newCatalog{ section = { type = "want-to-read" }, capability_error = 503 }
|
||||
assertEqual(catalog:dashboardSectionRequest(), nil, "a transient probe failure does not select the new route")
|
||||
catalog.content_w = 320
|
||||
slots, card_w, grid_h = catalog:sectionRowMetrics(12, 6)
|
||||
assertEqual(slots, 4, "slot count shrinks until the minimum cover width fits")
|
||||
assertEqual(card_w, 75, "the narrower grid recomputes its card width")
|
||||
assertEqual(grid_h, 95, "the narrower grid recomputes its card height")
|
||||
|
||||
-- The books come from the section when the body carries one, and from the
|
||||
-- legacy discover field otherwise.
|
||||
assertEqual(#CatalogDashboard.dashboardSectionBooks({ discover = { { id = 1 } } }), 1, "a legacy body reads discover")
|
||||
assertEqual(CatalogDashboard.dashboardSectionBooks({ discover = { { id = 1 } }, section = { books = { { id = 2 } } } })[1].id, 2,
|
||||
"a section body wins over the legacy field")
|
||||
assertEqual(#CatalogDashboard.dashboardSectionBooks(nil), 0, "no body yields no books")
|
||||
-- A shelf under a height budget gets more, narrower cards. The row height is
|
||||
-- always the height those cards need, so covers are never drawn out of aspect.
|
||||
catalog.content_w = 500
|
||||
slots, card_w, grid_h = catalog:shelfRowMetrics(12, 6, 999)
|
||||
assertEqual(slots, 5, "a shelf with room to spare keeps its natural row")
|
||||
assertEqual(grid_h, 115, "an unconstrained shelf keeps its natural height")
|
||||
|
||||
-- Thumbnail prefetching has to see the configured row's books, not just discover.
|
||||
slots, card_w, grid_h = catalog:shelfRowMetrics(12, 6, 100)
|
||||
assertEqual(slots, 6, "a tight shelf fits more cards per row")
|
||||
assertEqual(card_w, 78, "the extra card makes every card narrower")
|
||||
assertEqual(grid_h, card_w + 20, "the row height is the height the narrower card needs")
|
||||
|
||||
slots, card_w, grid_h = catalog:shelfRowMetrics(12, 6, 1)
|
||||
assertEqual(card_w, 78, "cards never shrink past their minimum width")
|
||||
assertEqual(grid_h, card_w + 20, "an impossible budget still yields an in-aspect row")
|
||||
|
||||
-- Thumbnail prefetching sees every configured grid slot, including all twelve
|
||||
-- prefetched items per shelf.
|
||||
local twelve = {}
|
||||
for id = 1, 12 do twelve[id] = { id = id + 10 } end
|
||||
local prefetch = CatalogDashboard.dashboardBooks({
|
||||
continueReading = { { id = 1 } },
|
||||
section = { books = { { id = 2 }, { id = 3 } } },
|
||||
dashboardSlots = {
|
||||
[1] = { type = "recently-added", books = twelve },
|
||||
[2] = { type = "want-to-read", books = { { id = 30 }, { id = 31 } } },
|
||||
},
|
||||
})
|
||||
assertEqual(#prefetch, 3, "prefetching covers continue reading and the configured row")
|
||||
assertEqual(#prefetch, 15, "prefetching covers Continue reading and every grid slot")
|
||||
|
||||
-- A successful request for a named section caches the body under that section's
|
||||
-- signature, so a later open recognises it.
|
||||
catalog = newCatalog{ section = { type = "want-to-read" }, capabilities = { "catalogDashboardSections" } }
|
||||
local _, refreshed = catalog:dashboardRoot()
|
||||
assertEqual(last_section.type, "want-to-read", "the configured section reaches the client")
|
||||
assertEqual(refreshed.dashboard.section.books[1].id, 9, "the section body is what gets rendered")
|
||||
assertEqual(catalog.settings.catalog_dashboard_cache_section, "want-to-read", "the cache records which section it holds")
|
||||
assertEqual(catalog:dashboardCacheMatchesSection(), true, "the cache matches the configuration it was fetched for")
|
||||
|
||||
-- A server that advertises the capability but rejects the parameter is a
|
||||
-- definitive answer: retry once without it rather than show nothing.
|
||||
-- Real book shelves use the ordinary catalog books endpoint and request twelve
|
||||
-- items, including filters selected through the existing catalog lists.
|
||||
catalog = newCatalog{
|
||||
section = { type = "want-to-read" },
|
||||
capabilities = { "catalogDashboardSections" },
|
||||
reject_section = 400,
|
||||
sections = {
|
||||
{ type = "recently-added" },
|
||||
author_source,
|
||||
schemaVersion = DashboardSections.LEGACY_SCHEMA_VERSION,
|
||||
},
|
||||
}
|
||||
local _, recovered = catalog:dashboardRoot()
|
||||
assertEqual(recovered.dashboard ~= nil, true, "a rejected parameter still produces a dashboard")
|
||||
assertEqual(recovered.dashboard.section, nil, "the retry falls back to the legacy body")
|
||||
assertEqual(Capabilities.supports(catalog.client, DashboardSections.CAPABILITY), false, "the rejection downgrades the session")
|
||||
local _, refreshed = catalog:dashboardRoot()
|
||||
assertEqual(refreshed.dashboard.dashboardSlots[1].type, "stats", "slot 1 keeps its native Stats renderer")
|
||||
assertEqual(refreshed.dashboard.dashboardSlots[2].type, "continue-reading", "slot 2 keeps its native Continue reading renderer")
|
||||
assertEqual(refreshed.dashboard.dashboardSlots[3].type, "recently-added", "the first old row migrates to slot 3")
|
||||
assertEqual(refreshed.dashboard.dashboardSlots[3].books[1].id, 11, "Recently added loads from catalog books")
|
||||
assertEqual(refreshed.dashboard.dashboardSlots[4].books[1].id, 13, "the selected author loads independently")
|
||||
assertEqual(catalog.settings.catalog_dashboard_cache_section,
|
||||
"stats|continue-reading|recently-added|authors:author=Ursula K. Le Guin:sort=title",
|
||||
"the cache records the selected catalog filter")
|
||||
assertEqual(catalog:dashboardCacheMatchesSection(), true, "the cache matches the full four-slot configuration")
|
||||
assertEqual(last_books_params.size, 12, "a shelf requests a full page of books")
|
||||
assertEqual(last_books_params.page, 1, "a shelf always requests its first page")
|
||||
|
||||
-- A cached body fetched for another section is still shown, but its row is
|
||||
-- marked pending so stale books are never presented as the new choice.
|
||||
-- Slots pointing at the same source share one request rather than fetching the
|
||||
-- same books twice.
|
||||
catalog = newCatalog{
|
||||
section = { type = "want-to-read" },
|
||||
cache = { continueReading = {}, discover = { { id = 2 } } },
|
||||
cache_section = "random",
|
||||
sections = {
|
||||
{ type = "random" },
|
||||
{ type = "recently-added" },
|
||||
{ type = "recently-added" },
|
||||
{ type = "random" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
},
|
||||
}
|
||||
local _, deduped = catalog:dashboardRoot()
|
||||
assertEqual(#requests, 2, "four shelves over two sources cost two requests")
|
||||
assertEqual(requests[2], "books:recently_added", "the repeated source is fetched once")
|
||||
assertEqual(deduped.dashboard.dashboardSlots[3].books[1].id, 11, "the repeated shelf still gets its books")
|
||||
assertEqual(deduped.dashboard.dashboardSlots[1].books, deduped.dashboard.dashboardSlots[4].books,
|
||||
"two Discover slots render the same fetched list")
|
||||
|
||||
-- Discover falls back to the dedicated endpoint when the dashboard payload does
|
||||
-- not include a preloaded discover list.
|
||||
catalog = newCatalog{ omit_discover = true }
|
||||
local _, discovered = catalog:dashboardRoot()
|
||||
assertEqual(discovered.dashboard.dashboardSlots[3].books[1].id, 14,
|
||||
"a missing dashboard discover list falls back to catalogDiscover")
|
||||
assertEqual(requests[2], "discover", "the dedicated discover endpoint is requested")
|
||||
|
||||
-- A failed shelf request only empties that shelf; the dashboard and later slots
|
||||
-- remain available.
|
||||
catalog = newCatalog{
|
||||
sections = {
|
||||
{ type = "stats" },
|
||||
{ type = "recently-added" },
|
||||
{ type = "in-progress" },
|
||||
{ type = "browse" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
},
|
||||
catalog_books_error = { recently_added = 500 },
|
||||
}
|
||||
local _, partial = catalog:dashboardRoot()
|
||||
assertEqual(partial.dashboard ~= nil, true, "one shelf failure does not discard the dashboard")
|
||||
assertEqual(#partial.dashboard.dashboardSlots[2].books, 0, "the failed shelf degrades to an empty list")
|
||||
assertEqual(partial.dashboard.dashboardSlots[3].books[1].id, 12, "later shelves continue loading")
|
||||
|
||||
-- A cached body fetched for another four-slot configuration is still shown, but
|
||||
-- its grid shelves are marked pending until the refresh lands.
|
||||
catalog = newCatalog{
|
||||
section = { type = "recently-added" },
|
||||
cache = { continueReading = {}, dashboardSlots = {} },
|
||||
cache_section = "stats|continue-reading|random|browse",
|
||||
}
|
||||
local _, cached_context = catalog:initialDashboardContext()
|
||||
assertEqual(cached_context.dashboard ~= nil, true, "the rest of the cached dashboard is still shown")
|
||||
assertEqual(cached_context.section_stale, true, "a cache from another section marks the row pending")
|
||||
assertEqual(cached_context.section_stale[3], true, "a cache from another slot configuration marks grid slots pending")
|
||||
|
||||
catalog = newCatalog{
|
||||
section = { type = "want-to-read" },
|
||||
cache = { continueReading = {}, discover = {} },
|
||||
cache_section = "want-to-read",
|
||||
section = { type = "recently-added" },
|
||||
cache = { continueReading = {}, dashboardSlots = {} },
|
||||
cache_section = "stats|continue-reading|recently-added|browse",
|
||||
}
|
||||
local _, matching_context = catalog:initialDashboardContext()
|
||||
assertEqual(matching_context.section_stale, false, "a cache from the same section is used as is")
|
||||
assertEqual(matching_context.section_stale, nil, "a cache from the same four-slot configuration is used as is")
|
||||
|
||||
-- Only a random row offers a reshuffle; every other source has an order the
|
||||
-- server chose.
|
||||
@@ -234,21 +303,33 @@ catalog = newCatalog{}
|
||||
assertEqual(catalog:dashboardSectionSupportsReroll({ type = "random" }), true, "Discover can be rerolled")
|
||||
assertEqual(catalog:dashboardSectionSupportsReroll({ type = "recently-added" }), false, "an ordered source cannot be rerolled")
|
||||
|
||||
-- Choosing a new section persists it and marks the visible row pending until
|
||||
-- the refresh lands.
|
||||
-- Choosing a new slot source persists it and marks all cached grid shelves
|
||||
-- pending until the refresh lands.
|
||||
catalog = newCatalog{}
|
||||
catalog.current_context = { kind = "dashboard", dashboard = { continueReading = {}, discover = {} } }
|
||||
catalog.current_context = { kind = "dashboard", dashboard = { continueReading = {}, dashboardSlots = {} } }
|
||||
local refreshes = 0
|
||||
function catalog:dashboardMode() return true end
|
||||
function catalog:updateItems() end
|
||||
function catalog:refreshCurrent() refreshes = refreshes + 1 end
|
||||
catalog:setDashboardSection({ type = "up-next-in-series" })
|
||||
assertEqual(catalog.settings[DashboardSections.SETTING_KEY][1].type, "up-next-in-series", "the new choice is persisted")
|
||||
assertEqual(catalog.current_context.section_stale, true, "the visible row is marked pending")
|
||||
assertEqual(refreshes, 1, "choosing a section refreshes the dashboard")
|
||||
catalog:setDashboardSection(author_source, 3)
|
||||
assertEqual(catalog.settings[DashboardSections.SETTING_KEY][3].type, "authors", "the new slot choice is persisted")
|
||||
assertEqual(catalog.current_context.section_stale[3], true, "only the changed dashboard slot is marked pending")
|
||||
assertEqual(refreshes, 1, "choosing a slot source refreshes the dashboard")
|
||||
|
||||
-- Re-picking the current section is a no-op rather than a needless refresh.
|
||||
catalog:setDashboardSection({ type = "up-next-in-series" })
|
||||
assertEqual(refreshes, 1, "re-picking the same section does not refresh")
|
||||
-- Re-picking the current slot source is a no-op rather than a needless refresh.
|
||||
catalog:setDashboardSection(author_source, 3)
|
||||
assertEqual(refreshes, 1, "re-picking the same slot source does not refresh")
|
||||
|
||||
-- Restoring the defaults rewrites every slot but still refreshes once, and
|
||||
-- marks the pending slots as the renderer expects to read them.
|
||||
catalog:setDashboardSections(DashboardSections.normalize(nil))
|
||||
assertEqual(catalog.settings[DashboardSections.SETTING_KEY][3].type, "random", "the reset restores the default slot sources")
|
||||
assertEqual(refreshes, 2, "a reset refreshes the dashboard once, not once per slot")
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
assertEqual(catalog.current_context.section_stale[index], true, "a reset marks every slot pending")
|
||||
end
|
||||
|
||||
catalog:setDashboardSections(DashboardSections.normalize(nil))
|
||||
assertEqual(refreshes, 2, "resetting an already-default dashboard does not refresh")
|
||||
|
||||
print("bookorbit_catalog_dashboard_section_test.lua: ok")
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package.loaded["gettext"] = function(text)
|
||||
return text
|
||||
end
|
||||
|
||||
package.loaded["ffi/util"] = {
|
||||
template = function(text, ...)
|
||||
local values = { ... }
|
||||
return (text:gsub("%%(%d+)", function(index)
|
||||
return tostring(values[tonumber(index)])
|
||||
end))
|
||||
end,
|
||||
}
|
||||
|
||||
local closed = {}
|
||||
local next_ticks = {}
|
||||
package.loaded["ui/uimanager"] = {
|
||||
close = function(_, widget)
|
||||
table.insert(closed, widget)
|
||||
end,
|
||||
nextTick = function(_, callback)
|
||||
table.insert(next_ticks, callback)
|
||||
end,
|
||||
}
|
||||
|
||||
package.loaded["device"] = { model = "test-device" }
|
||||
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
|
||||
package.loaded["ui/widget/inputdialog"] = {}
|
||||
package.loaded["ui/widget/multiinputdialog"] = {}
|
||||
package.loaded["ui/network/manager"] = {}
|
||||
package.loaded["ui/widget/notification"] = {}
|
||||
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
|
||||
package.loaded["util"] = {
|
||||
trim = function(value)
|
||||
return tostring(value or ""):match("^%s*(.-)%s*$")
|
||||
end,
|
||||
}
|
||||
package.loaded["bookorbit_api"] = {}
|
||||
package.loaded["bookorbit_highlight_diagnostics"] = {}
|
||||
package.loaded["bookorbit_sweep"] = {
|
||||
syncStatus = function()
|
||||
return { lastSweepAt = 0, matched = 0, unmatched = 0 }
|
||||
end,
|
||||
}
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||
|
||||
local flushes = 0
|
||||
G_reader_settings = {
|
||||
flush = function()
|
||||
flushes = flushes + 1
|
||||
end,
|
||||
}
|
||||
|
||||
local MainMenu = require("bookorbit_main_menu")
|
||||
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||
|
||||
local function assertEqual(actual, expected, label)
|
||||
if actual ~= expected then
|
||||
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
|
||||
end
|
||||
end
|
||||
|
||||
local function resetEvents()
|
||||
closed = {}
|
||||
next_ticks = {}
|
||||
flushes = 0
|
||||
end
|
||||
|
||||
local function newPlugin()
|
||||
local plugin = {
|
||||
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
|
||||
settings = {},
|
||||
}
|
||||
MainMenu.install(plugin)
|
||||
return plugin
|
||||
end
|
||||
|
||||
local plugin = newPlugin()
|
||||
local menu_container = { id = "dashboard-menu-container" }
|
||||
local touchmenu = { id = "inner-touch-menu", updates = 0, menu_closes = 0 }
|
||||
function touchmenu:updateItems()
|
||||
self.updates = self.updates + 1
|
||||
end
|
||||
-- A TouchMenu closes itself through closeMenu(), which runs the close_callback
|
||||
-- that owns its container. Closing the widget directly would leave that behind.
|
||||
function touchmenu:closeMenu()
|
||||
self.menu_closes = self.menu_closes + 1
|
||||
end
|
||||
|
||||
local loaded
|
||||
local catalog = {
|
||||
loadSection = function(_, section, opts)
|
||||
loaded = { section = section, index = opts.dashboard_source_index }
|
||||
end,
|
||||
}
|
||||
|
||||
plugin.dashboard_menu_container = menu_container
|
||||
plugin:chooseDashboardCatalogSource(3, "authors", catalog, touchmenu)
|
||||
assertEqual(#closed, 1, "catalog selection closes one menu container")
|
||||
assertEqual(closed[1], menu_container, "catalog selection closes the outer dashboard menu container")
|
||||
assertEqual(touchmenu.menu_closes, 0, "the outer container's close covers the menu inside it")
|
||||
assertEqual(plugin.dashboard_menu_container, nil, "the closed dashboard menu container is cleared")
|
||||
assertEqual(#next_ticks, 1, "catalog selection is deferred until the menu closes")
|
||||
assertEqual(loaded, nil, "the catalog is not opened before the deferred callback")
|
||||
next_ticks[1]()
|
||||
assertEqual(loaded.section, "authors", "the selector opens the requested catalog section")
|
||||
assertEqual(loaded.index, 3, "the selector preserves the target dashboard slot")
|
||||
|
||||
resetEvents()
|
||||
loaded = nil
|
||||
plugin.dashboard_menu_container = nil
|
||||
plugin:chooseDashboardCatalogSource(4, "series", catalog, touchmenu)
|
||||
assertEqual(#closed, 0, "the invoking touch menu is not closed as a bare widget")
|
||||
assertEqual(touchmenu.menu_closes, 1, "without an outer container the invoking touch menu closes itself")
|
||||
next_ticks[1]()
|
||||
assertEqual(loaded.section, "series", "fallback selection opens the requested section")
|
||||
assertEqual(loaded.index, 4, "fallback selection preserves the target slot")
|
||||
|
||||
resetEvents()
|
||||
loaded = nil
|
||||
local opened_with
|
||||
plugin.openCatalogBrowser = function(_, prefer_cached_dashboard)
|
||||
opened_with = prefer_cached_dashboard
|
||||
plugin.catalog_browser = catalog
|
||||
end
|
||||
plugin:chooseDashboardCatalogSource(2, "libraries", nil, nil)
|
||||
assertEqual(opened_with, false, "selection opens a fresh catalog when no catalog instance exists")
|
||||
assertEqual(#next_ticks, 1, "a newly opened catalog is entered on the next tick")
|
||||
next_ticks[1]()
|
||||
assertEqual(loaded.section, "libraries", "the new catalog opens at the requested selector")
|
||||
assertEqual(loaded.index, 2, "the new catalog receives the requested slot")
|
||||
|
||||
resetEvents()
|
||||
local selected
|
||||
catalog.setDashboardSection = function(_, config, index)
|
||||
selected = { config = config, index = index }
|
||||
end
|
||||
plugin:applyDashboardSection(1, { type = "stats" }, catalog, touchmenu)
|
||||
assertEqual(selected.config.type, "stats", "direct choices are applied through the active catalog")
|
||||
assertEqual(selected.index, 1, "direct choices preserve the target slot")
|
||||
assertEqual(touchmenu.updates, 1, "the invoking menu refreshes after a direct choice")
|
||||
|
||||
-- Restoring the defaults goes through the catalog in one write, so the slots
|
||||
-- and the dashboard's cache signature stay in step.
|
||||
resetEvents()
|
||||
local reset_to
|
||||
catalog.setDashboardSections = function(_, sections)
|
||||
reset_to = sections
|
||||
end
|
||||
plugin:resetDashboardSections(catalog, touchmenu)
|
||||
assertEqual(reset_to[1].type, "stats", "the reset restores the default slot 1")
|
||||
assertEqual(reset_to[3].type, "random", "the reset restores the default slot 3")
|
||||
assertEqual(reset_to.schemaVersion, DashboardSections.SCHEMA_VERSION, "the reset writes the current schema marker")
|
||||
assertEqual(touchmenu.updates, 2, "the invoking menu refreshes after a reset")
|
||||
|
||||
-- Without an open catalog the reset writes the setting itself.
|
||||
resetEvents()
|
||||
plugin.settings[DashboardSections.SETTING_KEY] = { { type = "recently-added" } }
|
||||
plugin:resetDashboardSections(nil, nil)
|
||||
assertEqual(plugin.settings[DashboardSections.SETTING_KEY][3].type, "random", "a reset without a catalog still restores the defaults")
|
||||
assertEqual(flushes, 1, "a reset without a catalog persists the settings")
|
||||
|
||||
local items = plugin:dashboardSectionItems(4, catalog)
|
||||
local function findItem(text)
|
||||
for _, item in ipairs(items) do
|
||||
if item.text == text then return item end
|
||||
end
|
||||
end
|
||||
assertEqual(findItem("All Books"), nil, "All Books remains a destination inside Browse")
|
||||
assertEqual(findItem("On device"), nil, "On device remains a destination inside Browse")
|
||||
|
||||
local selectors = {
|
||||
Authors = "authors",
|
||||
Series = "series",
|
||||
Collections = "collections",
|
||||
Libraries = "libraries",
|
||||
SmartScopes = "smart-scopes",
|
||||
}
|
||||
for label, section in pairs(selectors) do
|
||||
local item = findItem(label)
|
||||
assertEqual(item ~= nil, true, label .. " is offered as a slot source")
|
||||
resetEvents()
|
||||
loaded = nil
|
||||
item.callback(touchmenu)
|
||||
assertEqual(item.mandatory, ">", label .. " exposes a drill-down indicator")
|
||||
assertEqual(item.keep_menu_open, true, label .. " is marked as a selector")
|
||||
next_ticks[1]()
|
||||
assertEqual(loaded.section, section, label .. " callback opens the matching catalog section")
|
||||
assertEqual(loaded.index, 4, label .. " callback preserves the configured slot")
|
||||
end
|
||||
|
||||
print("bookorbit_dashboard_catalog_selection_test.lua: ok")
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Proves the dashboard's vertical budget: that shelves are measured at the
|
||||
-- height their cover cards actually need, that a page too tight for the
|
||||
-- configured slots drops the stats strip before any shelf, and that nothing is
|
||||
-- laid out past the available height.
|
||||
|
||||
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
|
||||
package.loaded["util"] = {
|
||||
fixUtf8 = function(value) return value end,
|
||||
urlEncode = function(value) return value end,
|
||||
}
|
||||
package.loaded["ffi/util"] = { template = function(value) return value end }
|
||||
package.loaded["gettext"] = function(text) return text end
|
||||
|
||||
local function widgetClass(height)
|
||||
return {
|
||||
new = function(_, opts)
|
||||
opts = opts or {}
|
||||
if not opts.getSize then
|
||||
function opts:getSize()
|
||||
return { w = 100, h = self.width or height or 20 }
|
||||
end
|
||||
end
|
||||
return opts
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
package.loaded["ffi/blitbuffer"] = { COLOR_DARK_GRAY = 1, COLOR_LIGHT_GRAY = 2 }
|
||||
package.loaded["ui/widget/button"] = widgetClass()
|
||||
package.loaded["ui/widget/container/centercontainer"] = widgetClass()
|
||||
package.loaded["ui/font"] = { getFace = function() return {} end }
|
||||
package.loaded["ui/geometry"] = widgetClass()
|
||||
package.loaded["ui/widget/horizontalgroup"] = widgetClass(0)
|
||||
package.loaded["ui/widget/horizontalspan"] = widgetClass(0)
|
||||
package.loaded["ui/widget/infomessage"] = widgetClass()
|
||||
package.loaded["ui/widget/linewidget"] = widgetClass()
|
||||
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
|
||||
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
|
||||
package.loaded["ui/size"] = {
|
||||
line = { thin = 1 },
|
||||
padding = { large = 8 },
|
||||
span = { horizontal_default = 4, vertical_default = 4 },
|
||||
}
|
||||
package.loaded["ui/widget/textboxwidget"] = widgetClass()
|
||||
package.loaded["ui/uimanager"] = { show = function() end }
|
||||
package.loaded["ui/widget/verticalgroup"] = widgetClass(0)
|
||||
package.loaded["ui/widget/verticalspan"] = widgetClass()
|
||||
package.loaded["bookorbit_stats_reader"] = {
|
||||
getReadingSummary = function()
|
||||
return { today_seconds = 600, week_seconds = 3600, streak_days = 3 }
|
||||
end,
|
||||
}
|
||||
|
||||
local HEADER_HEIGHT = 20
|
||||
|
||||
package.loaded["bookorbit_catalog_widgets"] = {
|
||||
DashboardCoverCard = widgetClass(),
|
||||
DashboardHeroCard = widgetClass(),
|
||||
DashboardBrowseRow = widgetClass(),
|
||||
DashboardIconButton = widgetClass(),
|
||||
buildDashboardSectionHeader = function()
|
||||
return { getSize = function() return { w = 100, h = HEADER_HEIGHT } end }
|
||||
end,
|
||||
coverCardHeight = function(card_w)
|
||||
return card_w + 20
|
||||
end,
|
||||
}
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||
|
||||
local CatalogDashboard = require("bookorbit_catalog_dashboard")
|
||||
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||
|
||||
local function assertEqual(actual, expected, label)
|
||||
if actual ~= expected then
|
||||
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
|
||||
end
|
||||
end
|
||||
|
||||
local STATS_HEIGHT = 60
|
||||
|
||||
local function books(count)
|
||||
local list = {}
|
||||
for id = 1, count do list[id] = { id = id } end
|
||||
return list
|
||||
end
|
||||
|
||||
local function newCatalog(opts)
|
||||
local slots = {}
|
||||
for index = 1, DashboardSections.SLOT_COUNT do
|
||||
slots[index] = { type = opts.sections[index].type, books = books(opts.books or 12) }
|
||||
end
|
||||
local catalog = {
|
||||
settings = {
|
||||
[DashboardSections.SETTING_KEY] = {
|
||||
opts.sections[1], opts.sections[2], opts.sections[3], opts.sections[4],
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
},
|
||||
},
|
||||
available_height = opts.avail,
|
||||
content_w = opts.content_w or 500,
|
||||
content_inset = 0,
|
||||
item_group = {},
|
||||
layout = {},
|
||||
current_context = {
|
||||
kind = "dashboard",
|
||||
dashboard = { continueReading = books(4), dashboardSlots = slots },
|
||||
},
|
||||
shelves = {},
|
||||
headers = {},
|
||||
}
|
||||
for name, fn in pairs(CatalogDashboard) do
|
||||
if name ~= "install" then catalog[name] = fn end
|
||||
end
|
||||
function catalog:prepareCustomUpdate() return nil end
|
||||
function catalog:finishCustomUpdate() end
|
||||
function catalog:ensureOnDeviceCurrent() end
|
||||
function catalog:isOnDevice() return false end
|
||||
function catalog:onDeviceCount() return 0 end
|
||||
function catalog:readStatusLabel() return nil end
|
||||
function catalog:buildDashboardStatsStrip()
|
||||
return { getSize = function() return { w = 100, h = STATS_HEIGHT } end }
|
||||
end
|
||||
|
||||
local addHeader = catalog.addDashboardHeader
|
||||
function catalog:addDashboardHeader(text, controls)
|
||||
table.insert(self.headers, text)
|
||||
return addHeader(self, text, controls)
|
||||
end
|
||||
local addGrid = catalog.addDashboardCoverGrid
|
||||
function catalog:addDashboardCoverGrid(section_id, list, height, with_progress, with_caption, cols, card_w, page, rows)
|
||||
table.insert(self.shelves, { id = section_id, height = height, cols = cols, card_w = card_w })
|
||||
return addGrid(self, section_id, list, height, with_progress, with_caption, cols, card_w, page, rows)
|
||||
end
|
||||
return catalog
|
||||
end
|
||||
|
||||
local DEFAULTS = {
|
||||
{ type = "stats" }, { type = "continue-reading" }, { type = "random" }, { type = "browse" },
|
||||
}
|
||||
local FOUR_SHELVES = {
|
||||
{ type = "random" }, { type = "recently-added" }, { type = "in-progress" }, { type = "random" },
|
||||
}
|
||||
|
||||
-- A roomy page renders every configured slot, and each shelf is exactly as tall
|
||||
-- as its cover cards need to be.
|
||||
local catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves, 1, "the default dashboard renders its one shelf")
|
||||
assertEqual(#catalog.headers, 3, "Continue reading, the shelf and Browse each get a header")
|
||||
assertEqual(catalog.shelves[1].height, catalog.shelves[1].card_w + 20, "a roomy shelf is drawn in aspect")
|
||||
assertEqual(catalog.shelves[1].cols, 5, "a roomy shelf keeps its natural slot count")
|
||||
assertEqual(catalog.dash_used <= 800, true, "a roomy page stays inside the available height")
|
||||
|
||||
-- Four shelves share the page and stay in aspect: the cards get narrower rather
|
||||
-- than being squashed to fit.
|
||||
catalog = newCatalog{ sections = FOUR_SHELVES, avail = 600 }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves, 4, "four configured shelves all render")
|
||||
for _, shelf in ipairs(catalog.shelves) do
|
||||
assertEqual(shelf.height, shelf.card_w + 20, "every shelf is drawn in aspect")
|
||||
end
|
||||
assertEqual(catalog.shelves[1].cols > 5, true, "a crowded page fits more cards per row")
|
||||
assertEqual(catalog.dash_used <= 600, true, "four shelves stay inside the available height")
|
||||
|
||||
-- Too tight for everything: the stats strip goes before any shelf does.
|
||||
catalog = newCatalog{ sections = DEFAULTS, avail = 450 }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves, 1, "the shelf survives a tight page")
|
||||
assertEqual(catalog.headers[1], "Continue reading", "the stats strip is dropped first")
|
||||
assertEqual(catalog.dash_used <= 450, true, "dropping stats brings the page back inside its height")
|
||||
|
||||
-- Tighter still: shelves go from the bottom up, and the native slots stay.
|
||||
catalog = newCatalog{ sections = FOUR_SHELVES, avail = 260 }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves < 4, true, "an impossible page drops shelves from the bottom")
|
||||
assertEqual(catalog.shelves[1].id, "section1", "the first configured shelf is the last to go")
|
||||
assertEqual(catalog.dash_used <= 260, true, "dropping shelves brings the page back inside its height")
|
||||
|
||||
-- A slot waiting on a refresh renders its placeholder rather than the books
|
||||
-- cached for the previous choice.
|
||||
catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
|
||||
catalog.current_context.section_stale = { [3] = true }
|
||||
catalog:updateDashboardItems()
|
||||
assertEqual(#catalog.shelves, 0, "a pending shelf renders no cover grid")
|
||||
assertEqual(catalog.headers[2], "Discover", "a pending shelf still shows its header")
|
||||
|
||||
print("bookorbit_dashboard_layout_test.lua: ok")
|
||||
@@ -0,0 +1,120 @@
|
||||
package.loaded["gettext"] = function(text)
|
||||
return text
|
||||
end
|
||||
|
||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||
|
||||
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||
|
||||
local function assertEqual(actual, expected, label)
|
||||
if actual ~= expected then
|
||||
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
|
||||
end
|
||||
end
|
||||
|
||||
local function assertNotSame(actual, expected, label)
|
||||
if actual == expected then
|
||||
error(label .. ": expected distinct tables")
|
||||
end
|
||||
end
|
||||
|
||||
local author_source = {
|
||||
type = "authors",
|
||||
sourceName = "Ursula K. Le Guin",
|
||||
params = {
|
||||
author = "Ursula K. Le Guin",
|
||||
sort = "title",
|
||||
ignored = "not persisted",
|
||||
},
|
||||
}
|
||||
|
||||
assertEqual(DashboardSections.normalizeEntry(nil).type, "random", "a missing entry degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "not-a-source" }).type, "random", "an unknown type degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "random", "an obsolete pseudo-source degrades to Discover")
|
||||
assertEqual(DashboardSections.normalizeEntry({ type = "authors" }).type, "random", "an unselected catalog type degrades to Discover")
|
||||
|
||||
local normalized_author = DashboardSections.normalizeEntry(author_source)
|
||||
assertEqual(normalized_author.type, "authors", "a selected author source is kept")
|
||||
assertEqual(normalized_author.sourceName, "Ursula K. Le Guin", "a selected author keeps its display name")
|
||||
assertEqual(normalized_author.params.author, "Ursula K. Le Guin", "a selected author keeps its filter")
|
||||
assertEqual(normalized_author.params.sort, "title", "a selected author keeps its sort")
|
||||
assertEqual(normalized_author.params.ignored, nil, "unknown catalog parameters are discarded")
|
||||
assertNotSame(normalized_author.params, author_source.params, "normalization defensively copies catalog parameters")
|
||||
|
||||
local defaults = DashboardSections.normalize(nil)
|
||||
assertEqual(#defaults, 4, "the dashboard always has four slots")
|
||||
assertEqual(defaults[1].type, "stats", "slot 1 defaults to Stats")
|
||||
assertEqual(defaults[2].type, "continue-reading", "slot 2 defaults to Continue reading")
|
||||
assertEqual(defaults[3].type, "random", "slot 3 defaults to Discover")
|
||||
assertEqual(defaults[4].type, "browse", "slot 4 defaults to Browse")
|
||||
assertNotSame(defaults[1], DashboardSections.DEFAULT_SLOTS[1], "default normalization returns copies")
|
||||
|
||||
-- What releases before the four-slot dashboard actually wrote to the device:
|
||||
-- a bare list of the configurable rows, with no schema marker at all. Reading
|
||||
-- one positionally would drop Stats and leave Discover in two slots, so an
|
||||
-- untagged list has to be recognised as the old shape.
|
||||
local migrated_one = DashboardSections.normalize({ { type = "recently-added" } })
|
||||
assertEqual(migrated_one[1].type, "stats", "one-row migration restores Stats")
|
||||
assertEqual(migrated_one[2].type, "continue-reading", "one-row migration restores Continue reading")
|
||||
assertEqual(migrated_one[3].type, "recently-added", "one-row migration places the old row in slot 3")
|
||||
assertEqual(migrated_one[4].type, "browse", "one-row migration restores Browse")
|
||||
assertEqual(migrated_one.schemaVersion, DashboardSections.SCHEMA_VERSION, "normalization writes the current schema marker")
|
||||
|
||||
local migrated_scope = DashboardSections.normalize({
|
||||
{ type = "smart-scope", smartScopeId = 4, smartScopeName = "Sci-fi" },
|
||||
})
|
||||
assertEqual(migrated_scope[1].type, "stats", "a stored SmartScope row still restores Stats")
|
||||
assertEqual(migrated_scope[3].type, "random", "an obsolete SmartScope row degrades to Discover in its own slot")
|
||||
assertEqual(migrated_scope[4].type, "browse", "a stored SmartScope row still restores Browse")
|
||||
|
||||
local migrated_two = DashboardSections.normalize({
|
||||
{ type = "want-to-read" },
|
||||
author_source,
|
||||
schemaVersion = DashboardSections.LEGACY_SCHEMA_VERSION,
|
||||
})
|
||||
assertEqual(migrated_two[3].type, "random", "an obsolete old row migrates safely to Discover")
|
||||
assertEqual(migrated_two[4].type, "authors", "a selected catalog source survives migration")
|
||||
|
||||
local current = DashboardSections.normalize({
|
||||
{ type = "recently-added" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
})
|
||||
assertEqual(current[1].type, "recently-added", "a tagged current list is read positionally")
|
||||
assertEqual(current[2].type, "continue-reading", "missing current slots use their positional defaults")
|
||||
|
||||
local repeated = DashboardSections.normalize({
|
||||
{ type = "stats" },
|
||||
{ type = "stats" },
|
||||
{ type = "browse" },
|
||||
{ type = "browse" },
|
||||
schemaVersion = DashboardSections.SCHEMA_VERSION,
|
||||
})
|
||||
assertEqual(repeated[1].type, "stats", "slot types may repeat")
|
||||
assertEqual(repeated[2].type, "stats", "a second Stats slot is retained")
|
||||
assertEqual(repeated[3].type, "browse", "a Browse slot is retained")
|
||||
assertEqual(repeated[4].type, "browse", "a repeated Browse slot is retained")
|
||||
|
||||
local stored = DashboardSections.storeAt({
|
||||
[DashboardSections.SETTING_KEY] = defaults,
|
||||
}, 2, author_source)
|
||||
assertEqual(stored[2].type, "authors", "storeAt updates the requested slot")
|
||||
assertEqual(stored[1].type, "stats", "storeAt leaves other slots unchanged")
|
||||
assertNotSame(stored[2].params, author_source.params, "storeAt does not retain caller-owned parameter tables")
|
||||
assertEqual(stored.schemaVersion, DashboardSections.SCHEMA_VERSION, "storeAt persists the current schema marker")
|
||||
|
||||
local clamped = DashboardSections.storeAt({
|
||||
[DashboardSections.SETTING_KEY] = { { type = "stats" }, schemaVersion = DashboardSections.SCHEMA_VERSION },
|
||||
}, 9, { type = "recently-added" })
|
||||
assertEqual(#clamped, DashboardSections.SLOT_COUNT, "storeAt never grows beyond the supported slot count")
|
||||
assertEqual(clamped[4].type, "recently-added", "an oversized index is constrained to the last slot")
|
||||
|
||||
assertEqual(DashboardSections.signature({ type = "recently-added" }), "recently-added", "a source signs as itself")
|
||||
assertEqual(DashboardSections.signature(author_source), "authors:author=Ursula K. Le Guin:sort=title", "catalog signatures include stable allowed filters")
|
||||
assertEqual(DashboardSections.headerText(author_source), "Ursula K. Le Guin", "a selected shelf is titled by its entry")
|
||||
assertEqual(DashboardSections.headerText({ type = "authors", sourceName = "" }), "Authors", "an empty source name falls back to the source label")
|
||||
assertEqual(DashboardSections.isBookSource("authors"), true, "a selected author is a book shelf source")
|
||||
assertEqual(DashboardSections.isBookSource("browse"), false, "Browse is not a book shelf source")
|
||||
assertEqual(DashboardSections.isCatalogSelector("series"), true, "Series requires one more selection level")
|
||||
assertEqual(DashboardSections.isCatalogSelector("random"), false, "Discover is directly selectable")
|
||||
|
||||
print("bookorbit_dashboard_sections_test.lua: ok")
|
||||
@@ -139,7 +139,11 @@ assertEqual(hasItem(file_manager_settings.sub_item_table, "Sync"), true, "file m
|
||||
assertEqual(hasItem(file_manager_settings.sub_item_table, "Plugin"), true, "file manager settings has plugin group")
|
||||
local file_manager_dashboard_settings = findItem(file_manager_settings.sub_item_table, "Dashboard")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "file manager dashboard settings has startup option")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Show below Continue reading (Discover)"), true, "file manager dashboard settings has the section picker")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 1 (Stats)"), true, "file manager dashboard settings has slot 1")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 2 (Continue reading)"), true, "file manager dashboard settings has slot 2")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 3 (Discover)"), true, "file manager dashboard settings has slot 3")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 4 (Browse)"), true, "file manager dashboard settings has slot 4")
|
||||
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Reset to Default"), true, "dashboard settings can restore the four defaults")
|
||||
local file_manager_sync_settings = findItem(file_manager_settings.sub_item_table, "Sync")
|
||||
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Auto sync current book"), false, "file manager sync settings hides reader auto sync")
|
||||
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "file manager sync settings has the two-way toggle")
|
||||
@@ -164,8 +168,11 @@ assertEqual(hasItem(dashboard_items, "Account & setup"), true, "dashboard mirror
|
||||
local dashboard_settings = findItem(dashboard_items, "Settings")
|
||||
local dashboard_dashboard_settings = findItem(dashboard_settings.sub_item_table, "Dashboard")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "dashboard settings has startup option")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Show below Continue reading (Discover)"), true, "dashboard settings has the section picker")
|
||||
assertEqual(hasItem(dashboard_items, "Show below Continue reading (Discover)"), false, "the section picker is not promoted to the dashboard menu top level")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 1 (Stats)"), true, "dashboard settings has slot 1")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 2 (Continue reading)"), true, "dashboard settings has slot 2")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 3 (Discover)"), true, "dashboard settings has slot 3")
|
||||
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 4 (Browse)"), true, "dashboard settings has slot 4")
|
||||
assertEqual(hasItem(dashboard_items, "Section 1 (Stats)"), false, "slot pickers are not promoted to the dashboard menu top level")
|
||||
local dashboard_sync_settings = findItem(dashboard_settings.sub_item_table, "Sync")
|
||||
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "dashboard sync settings has the two-way toggle")
|
||||
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "dashboard sync settings has offline skip toggle")
|
||||
|
||||
Reference in New Issue
Block a user