diff --git a/koreader-plugin/README.md b/koreader-plugin/README.md index 6c626058b..278045ad7 100644 --- a/koreader-plugin/README.md +++ b/koreader-plugin/README.md @@ -2,7 +2,7 @@ Syncs your KOReader reading life into BookOrbit: -- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device. +- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search, plus one configurable book row you choose the source of. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device. - **Progress sync**: pulls progress on book open (with a conflict prompt), pushes periodically and on close/suspend. - **Reading statistics**: uploads KOReader's per-page time events; BookOrbit turns them into reading sessions and daily stats. - **Two-way highlights**: device highlights appear as native highlights on the web reader; highlights, edits and deletions made on the web come back to the device. @@ -31,7 +31,8 @@ Re-downloading later (e.g. after changing your password or server address) and r 1. Tools > BookOrbit > "Auto sync current book" to sync the open book automatically. Leave the stock "Progress sync" plugin unconfigured to avoid double syncing. 2. Optional: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes). 3. Optional: Settings > Dashboard > "Open dashboard on startup". -4. Optional: assign "BookOrbit: sync this book" / "sync all books" to gestures. +4. Optional: Settings > Dashboard > "Show below Continue reading" - pick Discover (the default), Recently added, Want to read, Up next in series, or one of your SmartScopes. Needs a server new enough to advertise the section capability; older servers keep showing Discover. +5. Optional: assign "BookOrbit: sync this book" / "sync all books" to gestures. The plugin manages `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` to keep its menu entry pinned below Calibre across updates; delete those files to reset your menu order. diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua index 5f61c6b4e..773b73fdd 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua @@ -539,14 +539,26 @@ function BookOrbitApi:catalogRoot() return self:request("GET", "/koreader/plugin/catalog/root") end -function BookOrbitApi:catalogDashboard() - return self:request("GET", "/koreader/plugin/catalog/dashboard") +-- 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)) 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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua index ddf740922..bf5ae384f 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua @@ -3,12 +3,16 @@ 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), the Discover rows and a compact Browse list, including -the offline cache and the Discover reroll. Installed onto the catalog +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. +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 Discover itself. +too tight, the stats strip is dropped first, then the configurable row itself. ]] local Blitbuffer = require("ffi/blitbuffer") @@ -30,8 +34,10 @@ 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") local BookOrbitStatsReader = require("bookorbit_stats_reader") local formatDuration = CatalogUtil.formatDuration @@ -46,11 +52,12 @@ local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton -- How long the local reading-stats summary is reused before re-querying -- statistics.sqlite3 (the dashboard re-renders on every row page turn). local STATS_CACHE_TTL = 120 -local DISCOVER_TARGET_SLOTS = 5 -local DISCOVER_COMPACT_GAP = 6 +local SECTION_TARGET_SLOTS = 5 +local SECTION_COMPACT_GAP = 6 local DASHBOARD_TALL_ASPECT_RATIO = 1.55 -local DISCOVER_MAX_ROWS = 2 +local SECTION_MAX_ROWS = 2 local STATS_MIN_BODY_HEIGHT = 56 +local SECTION_PAGE_ID = "section" local CatalogDashboard = {} @@ -66,6 +73,46 @@ 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))) +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)) +end + +function CatalogDashboard:dashboardConfiguredSection() + return DashboardSections.primary(self.settings) +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, + }) +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 {} end function CatalogDashboard.dashboardItems() @@ -96,16 +143,26 @@ function CatalogDashboard:dashboardContext(dashboard, opts) stale = opts.stale == true, unavailable = opts.unavailable == true, loading = opts.loading == true, + section_stale = opts.section_stale == true, } end +-- A cached body is served whole, but its configurable row is only trusted when +-- it was fetched for the section that is configured now. +function CatalogDashboard:cachedDashboardContext(cached, opts) + opts = opts or {} + opts.stale = true + opts.section_stale = not self:dashboardCacheMatchesSection() + return self:dashboardContext(cached, opts) +end + -- What the catalog widget opens on, built without touching the network: the -- cached dashboard when there is one, otherwise a placeholder the pending -- refresh replaces in place. function CatalogDashboard:initialDashboardContext() local cached = self:dashboardCache() if cached then - return self:dashboardContext(cached, { stale = true }) + return self:cachedDashboardContext(cached) end if self:shouldRefreshDashboardOnOpen() then return self:dashboardContext(nil, { stale = true, loading = true }) @@ -123,18 +180,39 @@ 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() end local cached = self:dashboardCache() if self.prefer_cached_dashboard and cached and not NetworkMgr:isConnected() then - return self:dashboardContext(cached, { stale = true }) + return self:cachedDashboardContext(cached) end + local section = self:dashboardSectionRequest() local body, err = self:fetch(_("Loading dashboard..."), function() - return self.client:catalogDashboard() + return self.client:catalogDashboard(section) 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 self:cacheDashboard(body) return self:dashboardContext(body) @@ -151,7 +229,7 @@ function CatalogDashboard:dashboardRoot(opts) end if cached then - return self:dashboardContext(cached, { stale = true }) + return self:cachedDashboardContext(cached) end if err ~= "cancelled" and requestIsCurrent() then @@ -181,15 +259,15 @@ function CatalogDashboard:loadDashboardRoot(replace, opts) end) end --- All books rendered on the dashboard (continue reading + discover), used for --- thumbnail prefetching and cover-cache eviction. +-- All books rendered on the dashboard (continue reading + the configurable +-- row), used for thumbnail prefetching and cover-cache eviction. 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(dashboard.discover or {}) do + for _, book in ipairs(CatalogDashboard.dashboardSectionBooks(dashboard)) do table.insert(books, book) end return books @@ -290,6 +368,12 @@ function CatalogDashboard:addDashboardHeader(text, controls) end end +-- Reshuffling only means something for a random row; every other source has a +-- defined order the server chose. +function CatalogDashboard:dashboardSectionSupportsReroll(config) + return (config or {}).type == DashboardSections.DEFAULT_TYPE +end + function CatalogDashboard:buildDashboardRerollButton() local size = Screen:scaleBySize(24) return BookOrbitDashboardIconButton:new{ @@ -407,8 +491,8 @@ function CatalogDashboard:addDashboardHeroRow(books, height, slots, page) self.dash_used = self.dash_used + height end -function CatalogDashboard:discoverRowMetrics(count, gap) - local slots = math.min(DISCOVER_TARGET_SLOTS, count) +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) while slots > 1 @@ -626,7 +710,12 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim local context = self.current_context or {} local dashboard = context.dashboard local continue_books = dashboard and dashboard.continueReading or {} - local discover_books = dashboard and dashboard.discover 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 action_entries = self:dashboardActionEntries() local summary = self:dashboardStatsSummary() or { today_seconds = 0, week_seconds = 0, streak_days = 0 } @@ -651,36 +740,37 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim local stats_h = stats_widget and stats_widget:getSize().h or 0 local show_stats = stats_widget ~= nil - local show_discover = #discover_books > 0 - local discover_gap = inner_gap - local discover_slots = 0 - local discover_card_w = 0 - local discover_row_h = 0 - local discover_rows = 1 + 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 updateDiscoverMetrics() - if show_discover then - discover_slots, discover_card_w, discover_row_h = self:discoverRowMetrics(#discover_books, discover_gap) + 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 - discover_slots, discover_card_w, discover_row_h = 0, 0, 0 + section_slots, section_card_w, section_row_h = 0, 0, 0 end end - updateDiscoverMetrics() + updateSectionMetrics() - local function discoverGridHeight() - if not show_discover then return 0 end - return discover_rows * discover_row_h + math.max(0, discover_rows - 1) * inner_gap + 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 end -- Fixed blocks are measured up front. If the dashboard gets too tight, the - -- stats strip drops first, then Discover. + -- stats strip drops first, then the configurable row. local function fixedHeight() 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_discover and (header_h + inner_gap + discoverGridHeight() + section_gap) or 0) + + (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 @@ -688,35 +778,35 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim if show_stats and fixedHeight() > avail then show_stats = false end - if show_discover and fixedHeight() > avail then - show_discover = false - updateDiscoverMetrics() + if show_section and fixedHeight() > avail then + show_section = false + updateSectionMetrics() end - if show_discover then - local compact_gap = px(DISCOVER_COMPACT_GAP) - if compact_gap < discover_gap then - local previous_gap = discover_gap - local previous_slots, previous_card_w, previous_row_h = discover_slots, discover_card_w, discover_row_h - discover_gap = compact_gap - updateDiscoverMetrics() + 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 - discover_gap = previous_gap - discover_slots, discover_card_w, discover_row_h = previous_slots, previous_card_w, previous_row_h + section_row_gap = previous_gap + section_slots, section_card_w, section_row_h = previous_slots, previous_card_w, previous_row_h end end end - if show_discover and self:dashboardTallLayout() and discover_slots > 0 then - local full_rows = math.floor(#discover_books / discover_slots) - local remainder = #discover_books % discover_slots + 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(discover_slots * 0.5) then + if remainder >= math.ceil(section_slots * 0.5) then book_rows = book_rows + 1 end - local max_rows = math.min(DISCOVER_MAX_ROWS, math.max(1, book_rows)) - while discover_rows < max_rows do - discover_rows = discover_rows + 1 + 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 - discover_rows = discover_rows - 1 + section_rows = section_rows - 1 break end end @@ -765,23 +855,32 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim end self:addDashboardSpacer(section_gap) - if show_discover then - discover_slots = math.min(discover_slots, #discover_books) - local discover_page_size = math.max(1, discover_slots * discover_rows) - local discover_pages = math.max(1, math.ceil(#discover_books / discover_page_size)) - local discover_page = self:dashboardPage("discover", discover_pages) - local discover_controls = {} - if discover_pages > 1 then - local prev_button, next_button = self:buildDashboardHeaderNav("discover", discover_page, discover_pages) - table.insert(discover_controls, prev_button) - table.insert(discover_controls, next_button) + 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 - table.insert(discover_controls, self:buildDashboardRerollButton()) - self:addDashboardHeader(_("Discover"), discover_controls) + self:addDashboardHeader(DashboardSections.headerText(section_config), section_controls) self:addDashboardSpacer(inner_gap) - self:addDashboardCoverGrid("discover", discover_books, discover_row_h, false, false, - discover_slots, discover_card_w, discover_page, discover_rows) + 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) end @@ -794,8 +893,27 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim self:finishCustomUpdate(old_dimen, select_number) end +-- Replaces the configurable row's books in place, leaving the rest of the page +-- and its cache entry alone. +function CatalogDashboard:applyDashboardSectionBooks(context, books) + 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 + context.dash_pages = context.dash_pages or {} + context.dash_pages[SECTION_PAGE_ID] = 1 + context.section_stale = false + 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. +-- dashboard without reloading the rest of the page. Only reachable while the +-- configurable row is showing Discover. function CatalogDashboard:rerollDiscover() if not self:dashboardMode() then return end self:runConnected(function() @@ -803,21 +921,32 @@ function CatalogDashboard:rerollDiscover() return self.client:catalogDiscover() end) if body and body.discover then - local context = self.current_context - if context and context.dashboard then - context.dashboard.discover = body.discover - context.dash_pages = context.dash_pages or {} - context.dash_pages.discover = 1 - self:cacheDashboard(context.dashboard) - self:scheduleThumbnailDownloads(self.dashboardBooks(context.dashboard)) - self:updateItems() - end + self:applyDashboardSectionBooks(self.current_context, body.discover) 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) + local normalized = DashboardSections.normalizeEntry(config) + if DashboardSections.signature(normalized) == DashboardSections.signature(self:dashboardConfiguredSection()) then + return + end + self:persistSetting(DashboardSections.SETTING_KEY, DashboardSections.store(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 + self:updateItems() + end + self:refreshCurrent() +end + function CatalogDashboard.install(Catalog) for name, fn in pairs(CatalogDashboard) do if name ~= "install" then diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua new file mode 100644 index 000000000..8ca903643 --- /dev/null +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua @@ -0,0 +1,127 @@ +--[[-- +Registry for the configurable dashboard row below Continue reading. + +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. + +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. +]] + +local _ = require("gettext") + +local DashboardSections = {} + +DashboardSections.SETTING_KEY = "catalog_dashboard_sections" +DashboardSections.CAPABILITY = "catalogDashboardSections" +DashboardSections.DEFAULT_TYPE = "random" + +-- Order here is the order the picker lists them in. +DashboardSections.TYPES = { + "random", + "recently-added", + "want-to-read", + "up-next-in-series", + "smart-scope", +} + +local LABELS = { + ["random"] = function() return _("Discover") 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, +} + +local HELP_TEXT = { + ["random"] = function() return _("A random selection from your whole library, reshuffled with the button in the section header.") 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, +} + +function DashboardSections.isValid(section_type) + return LABELS[section_type] ~= nil +end + +function DashboardSections.label(section_type) + local label = LABELS[section_type] + return label and label() or LABELS[DashboardSections.DEFAULT_TYPE]() +end + +function DashboardSections.helpText(section_type) + local help = HELP_TEXT[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. +function DashboardSections.headerText(config) + config = config or {} + if config.type == "smart-scope" and type(config.smartScopeName) == "string" and config.smartScopeName ~= "" then + return config.smartScopeName + end + return DashboardSections.label(config.type) +end + +function DashboardSections.defaultConfig() + return { type = DashboardSections.DEFAULT_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. +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 + end + return entry +end + +function DashboardSections.normalize(value) + if type(value) ~= "table" or #value == 0 then + return { DashboardSections.defaultConfig() } + end + local normalized = {} + for _index, entry in ipairs(value) do + table.insert(normalized, DashboardSections.normalizeEntry(entry)) + end + return normalized +end + +-- The one section rendered today. +function DashboardSections.primary(settings) + local stored = settings and settings[DashboardSections.SETTING_KEY] + return DashboardSections.normalize(stored)[1] +end + +function DashboardSections.store(config) + return { DashboardSections.normalizeEntry(config) } +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) + end + return config.type +end + +return DashboardSections diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua index 0a8bade02..787a2c91d 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua @@ -21,6 +21,7 @@ local _ = require("gettext") local BookOrbitApi = require("bookorbit_api") local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics") local BookOrbitSweep = require("bookorbit_sweep") +local DashboardSections = require("bookorbit_dashboard_sections") -- Assigned from the plugin class on install so menu labels can name the -- shared sync strategies. @@ -286,7 +287,115 @@ function MainMenu:testConnection() end) end -function MainMenu:dashboardSettingsMenu() +function MainMenu:dashboardSectionLabel() + return DashboardSections.headerText(DashboardSections.primary(self.settings)) +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) + if catalog and catalog.setDashboardSection then + catalog:setDashboardSection(config) + else + self.settings[DashboardSections.SETTING_KEY] = DashboardSections.store(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()) +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 + 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, + }, + }) + end + end + dialog = ButtonDialog:new{ + title = _("Show which SmartScope?"), + buttons = buttons, + } + UIManager:show(dialog) +end + +function MainMenu:dashboardSectionItems(catalog) + local items = {} + for _index, section_type in ipairs(DashboardSections.TYPES) do + local is_smart_scope = section_type == "smart-scope" + 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, + help_text = DashboardSections.helpText(section_type), + checked_func = function() + return DashboardSections.primary(self.settings).type == section_type + end, + keep_menu_open = is_smart_scope, + callback = function(touchmenu_instance) + if is_smart_scope then + self:chooseDashboardSmartScope(catalog, touchmenu_instance) + else + self:applyDashboardSection({ type = section_type }, catalog, touchmenu_instance) + end + end, + }) + end + return items +end + +function MainMenu:dashboardSettingsMenu(catalog) return { { text_func = function() @@ -294,6 +403,12 @@ function MainMenu:dashboardSettingsMenu() 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), + }, } end @@ -385,7 +500,7 @@ function MainMenu:settingsMenu(has_open_book, opts) local items = { { text = _("Dashboard"), - sub_item_table = self:dashboardSettingsMenu(), + sub_item_table = self:dashboardSettingsMenu(opts.catalog), }, { text = _("Sync"), @@ -517,7 +632,7 @@ function MainMenu:dashboardMenuItems(catalog) id = "settings", text = _("Settings"), separator = item.separator, - sub_item_table = self:settingsMenu(false, { include_plugin = false }), + sub_item_table = self:settingsMenu(false, { include_plugin = false, catalog = catalog }), }) else table.insert(items, item) diff --git a/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua new file mode 100644 index 000000000..38266af9e --- /dev/null +++ b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua @@ -0,0 +1,254 @@ +-- 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. + +local connected = true + +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() + return { + new = function(_, opts) + opts = opts or {} + if not opts.getSize then + function opts:getSize() + return { w = 100, h = 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() +package.loaded["ui/widget/horizontalspan"] = widgetClass() +package.loaded["ui/widget/infomessage"] = widgetClass() +package.loaded["ui/widget/linewidget"] = widgetClass() +package.loaded["ui/network/manager"] = { + isConnected = function() + return connected + 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() +package.loaded["ui/widget/verticalspan"] = widgetClass() +package.loaded["bookorbit_stats_reader"] = {} +package.loaded["bookorbit_catalog_widgets"] = {} + +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") + +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 + +-- 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 requests +local last_section + +local function newCatalog(opts) + opts = opts or {} + Capabilities.reset() + requests = {} + last_section = nil + 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, + }, + 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 + return body + end, + }, + } + for name, fn in pairs(CatalogDashboard) do + if name ~= "install" then catalog[name] = fn end + end + function catalog:persistSetting(key, value) + self.settings[key] = value + end + function catalog:fetch(_, fn) + return fn() + end + return catalog +end + +-- Discover is the default, so the common case never probes for the capability +-- and never puts the parameter on the wire. +local catalog = newCatalog{} +assertEqual(catalog:dashboardSectionRequest(), nil, "the default row sends no section parameter") +assertEqual(#requests, 0, "the default row does not probe capabilities") + +-- 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") + +catalog = newCatalog{ section = { type = "want-to-read" }, capabilities = {} } +assertEqual(catalog:dashboardSectionRequest(), nil, "a server without the capability keeps the legacy request") + +-- 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") + +-- 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") + +-- Thumbnail prefetching has to see the configured row's books, not just discover. +local prefetch = CatalogDashboard.dashboardBooks({ + continueReading = { { id = 1 } }, + section = { books = { { id = 2 }, { id = 3 } } }, +}) +assertEqual(#prefetch, 3, "prefetching covers continue reading and the configured row") + +-- 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. +catalog = newCatalog{ + section = { type = "want-to-read" }, + capabilities = { "catalogDashboardSections" }, + reject_section = 400, +} +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") + +-- 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. +catalog = newCatalog{ + section = { type = "want-to-read" }, + cache = { continueReading = {}, discover = { { id = 2 } } }, + cache_section = "random", +} +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") + +catalog = newCatalog{ + section = { type = "want-to-read" }, + cache = { continueReading = {}, discover = {} }, + cache_section = "want-to-read", +} +local _, matching_context = catalog:initialDashboardContext() +assertEqual(matching_context.section_stale, false, "a cache from the same section is used as is") + +-- Only a random row offers a reshuffle; every other source has an order the +-- server chose. +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. +catalog = newCatalog{} +catalog.current_context = { kind = "dashboard", dashboard = { continueReading = {}, discover = {} } } +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") + +-- 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") + +print("bookorbit_catalog_dashboard_section_test.lua: ok") diff --git a/koreader-plugin/spec/bookorbit_menu_context_test.lua b/koreader-plugin/spec/bookorbit_menu_context_test.lua index 8bb41ec39..d859f0f90 100644 --- a/koreader-plugin/spec/bookorbit_menu_context_test.lua +++ b/koreader-plugin/spec/bookorbit_menu_context_test.lua @@ -139,6 +139,7 @@ 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") 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 highlight sync"), true, "file manager sync settings has highlight toggle") @@ -163,6 +164,8 @@ 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") local dashboard_sync_settings = findItem(dashboard_settings.sub_item_table, "Sync") assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlight sync"), true, "dashboard sync settings has highlight toggle") assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "dashboard sync settings has offline skip toggle") diff --git a/packages/types/src/koreader.ts b/packages/types/src/koreader.ts index 6b6daf071..4663b4afe 100644 --- a/packages/types/src/koreader.ts +++ b/packages/types/src/koreader.ts @@ -270,6 +270,32 @@ export interface KoreaderCatalogSectionResponse { query?: string | null; } +// The dashboard row below Continue reading is user-selectable. "random" +// reproduces the original Discover row and remains the default. +export const KOREADER_DASHBOARD_SECTION_TYPE = { + RANDOM: "random", + RECENTLY_ADDED: "recently-added", + WANT_TO_READ: "want-to-read", + UP_NEXT_IN_SERIES: "up-next-in-series", + SMART_SCOPE: "smart-scope", +} as const; + +export type KoreaderDashboardSectionType = (typeof KOREADER_DASHBOARD_SECTION_TYPE)[keyof typeof KOREADER_DASHBOARD_SECTION_TYPE]; +export const KOREADER_DASHBOARD_SECTION_TYPES = Object.values(KOREADER_DASHBOARD_SECTION_TYPE) as ReadonlyArray; + +// Ordered so a later release can render more than one configurable row without +// a wire change. Only the first entry is honoured today. +export interface KoreaderDashboardSectionConfig { + type: KoreaderDashboardSectionType; + smartScopeId?: number; +} + +export interface KoreaderCatalogDashboardSection { + type: KoreaderDashboardSectionType; + smartScopeId: number | null; + books: KoreaderCatalogBookListItem[]; +} + export interface KoreaderCatalogDashboardResponse { generatedAt: string; username: string; @@ -277,7 +303,11 @@ export interface KoreaderCatalogDashboardResponse { totalBooks: number; sections: KoreaderCatalogEntry[]; continueReading: KoreaderCatalogBookListItem[]; + // Legacy Discover row. Populated only for requests that name no section, so a + // plugin predating the section parameter keeps its original response and the + // books are never carried twice. discover: KoreaderCatalogBookListItem[]; + section?: KoreaderCatalogDashboardSection; readingGoal: ReadingGoalWidgetData; readingStreak: ReadingStreakWidgetData; highlightOfTheDay: HighlightOfTheDayWidgetData | null; @@ -287,6 +317,10 @@ export interface KoreaderCatalogDiscoverResponse { discover: KoreaderCatalogBookListItem[]; } +export interface KoreaderCatalogDashboardSectionResponse { + section: KoreaderCatalogDashboardSection; +} + // Everything a bulk download needs per downloadable file, so no per-book detail // request remains. `fileHash` is the KOReader partial MD5 of the exact bytes the // download route streams, so it doubles as the restart-validation checksum and @@ -325,7 +359,7 @@ export interface KoreaderCatalogManifestPage { restartRequired: boolean; } -export type KoreaderPluginCapability = "catalogBulkManifest"; +export type KoreaderPluginCapability = "catalogBulkManifest" | "catalogDashboardSections"; export interface KoreaderPluginVersionInfo { pluginVersion: string; diff --git a/server/src/modules/dashboard/dashboard.module.ts b/server/src/modules/dashboard/dashboard.module.ts index 62489b5f3..74b7371c9 100644 --- a/server/src/modules/dashboard/dashboard.module.ts +++ b/server/src/modules/dashboard/dashboard.module.ts @@ -13,6 +13,6 @@ import { DashboardWidgetService } from './dashboard-widget.service'; imports: [BookModule, LibraryModule, SmartScopeModule], controllers: [DashboardController], providers: [DashboardService, DashboardRepository, DashboardWidgetService, DashboardWidgetRepository], - exports: [DashboardWidgetService], + exports: [DashboardService, DashboardWidgetService], }) export class DashboardModule {} diff --git a/server/src/modules/dashboard/dashboard.service.ts b/server/src/modules/dashboard/dashboard.service.ts index 0d3f2cf4e..c0345b707 100644 --- a/server/src/modules/dashboard/dashboard.service.ts +++ b/server/src/modules/dashboard/dashboard.service.ts @@ -35,39 +35,55 @@ export class DashboardService { const clampedLimit = Math.min(Math.max(1, limit), MAX_LIMIT); if (type === ScrollerType.SMART_SCOPE) { - if (!smartScopeId || smartScopeId <= 0) { - throw new BadRequestException('smartScopeId is required and must be a positive integer when scroller type is smartScope'); - } - const result = await this.smartScopeService.executeSmartScope(smartScopeId, user, 0, clampedLimit); + const result = await this.smartScopeService.executeSmartScope(this.assertSmartScopeId(smartScopeId), user, 0, clampedLimit); return result.items; } + return this.loadCardsByIds(await this.findScrollerBookIds(type, user, clampedLimit), user.id); + } + + // Book-id selection without the web card assembly, so other modules can shape + // the same rows into their own response type. Smart scopes are excluded: they + // resolve through SmartScopeService, which returns assembled cards directly. + async getScrollerBookIds(type: Exclude, user: RequestUser, limit: number): Promise { + return this.findScrollerBookIds(type, user, Math.min(Math.max(1, limit), MAX_LIMIT)); + } + + async getSmartScopeBookIds(smartScopeId: number | undefined, user: RequestUser, limit: number): Promise { + const result = await this.smartScopeService.executeSmartScope( + this.assertSmartScopeId(smartScopeId), + user, + 0, + Math.min(Math.max(1, limit), MAX_LIMIT), + ); + return result.items.map((item) => item.id); + } + + private assertSmartScopeId(smartScopeId?: number): number { + if (!smartScopeId || smartScopeId <= 0) { + throw new BadRequestException('smartScopeId is required and must be a positive integer when scroller type is smartScope'); + } + return smartScopeId; + } + + private async findScrollerBookIds(type: Exclude, user: RequestUser, clampedLimit: number): Promise { const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user); if (accessibleLibraryIds.length === 0) return []; const contentFilters = user.isSuperuser ? undefined : user.contentFilters; - let bookIds: number[]; switch (type) { case ScrollerType.RECENTLY_ADDED: - bookIds = await this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters); case ScrollerType.CONTINUE_READING: - bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); case ScrollerType.CONTINUE_LISTENING: - bookIds = await this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); case ScrollerType.WANT_TO_READ: - bookIds = await this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); case ScrollerType.UP_NEXT_IN_SERIES: - bookIds = await this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); case ScrollerType.RANDOM: - bookIds = await this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); - break; + return this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters); } - - return this.loadCardsByIds(bookIds, user.id); } } diff --git a/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts b/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts index 1747fcb2c..d97f10c7e 100644 --- a/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts +++ b/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts @@ -1,11 +1,13 @@ import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator'; +import { KOREADER_DASHBOARD_SECTION_TYPES } from '@bookorbit/types'; import type { KoreaderCatalogReadStatusFilter, KoreaderCatalogSettableReadStatus, KoreaderCatalogSort, KoreaderCatalogSortOrder, + KoreaderDashboardSectionType, } from '@bookorbit/types'; import { KOREADER_DEVICE_ID_REGEX } from './koreader-device-param.dto'; @@ -197,6 +199,28 @@ export class KoreaderCatalogManifestQueryDto { seriesId?: number; } +// Omitting `section` yields the pre-section dashboard response, so a plugin +// that predates the configurable row keeps working against a newer server. +export class KoreaderCatalogDashboardQueryDto { + @IsOptional() + @IsIn(KOREADER_DASHBOARD_SECTION_TYPES) + section?: KoreaderDashboardSectionType; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + smartScopeId?: number; +} + +export class KoreaderCatalogDashboardSectionQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + smartScopeId?: number; +} + export class KoreaderCatalogSectionQueryDto { @IsOptional() @Type(() => Number) diff --git a/server/src/modules/koreader/koreader-catalog.controller.test.ts b/server/src/modules/koreader/koreader-catalog.controller.test.ts index 152b8e075..4ad4763cc 100644 --- a/server/src/modules/koreader/koreader-catalog.controller.test.ts +++ b/server/src/modules/koreader/koreader-catalog.controller.test.ts @@ -13,6 +13,7 @@ function makeController() { .fn() .mockResolvedValue({ items: [], total: 0, page: 1, size: 20, hasNext: false, hasPrevious: false, nextUrl: null, previousUrl: null }), getBookDetail: vi.fn().mockResolvedValue({ id: 10 }), + getDashboardSection: vi.fn().mockResolvedValue({ section: { type: 'want-to-read', smartScopeId: null, books: [] } }), getBulkManifest: vi.fn().mockResolvedValue({ items: [], hasNext: false, nextCursor: null, manifestVersion: 'lib-v1', restartRequired: false }), setReadStatus: vi.fn().mockResolvedValue({ readStatus: 'reading' }), setRating: vi.fn().mockResolvedValue({ rating: 4 }), @@ -37,7 +38,7 @@ describe('KoreaderCatalogController', () => { const bookDetailQuery = { deviceId: 'device-1' }; expect(controller.root()).toEqual({ sections: [] }); - await expect(controller.dashboard(user)).resolves.toEqual(expect.objectContaining({ generatedAt: '2026-06-26T00:00:00.000Z' })); + await expect(controller.dashboard(user, {})).resolves.toEqual(expect.objectContaining({ generatedAt: '2026-06-26T00:00:00.000Z' })); await expect(controller.sections(user, 'libraries', sectionQuery)).resolves.toEqual({ section: 'libraries', items: [] }); await expect(controller.books(user, query)).resolves.toEqual(expect.objectContaining({ total: 0 })); await expect(controller.bookDetail(user, 10, bookDetailQuery)).resolves.toEqual({ id: 10 }); @@ -48,7 +49,7 @@ describe('KoreaderCatalogController', () => { await controller.thumbnail(user, 10, reply, '"etag"'); await controller.download(user, 100, reply); - expect(catalogService.getDashboard).toHaveBeenCalledWith(user); + expect(catalogService.getDashboard).toHaveBeenCalledWith(user, undefined); expect(catalogService.getSectionEntries).toHaveBeenCalledWith(user, 'libraries', sectionQuery); expect(catalogService.getBooksPage).toHaveBeenCalledWith(user, query); expect(catalogService.getBookDetail).toHaveBeenCalledWith(user, 10, 'device-1'); @@ -58,4 +59,28 @@ describe('KoreaderCatalogController', () => { expect(catalogService.streamThumbnail).toHaveBeenCalledWith(user, 10, reply, '"etag"'); expect(catalogService.streamFile).toHaveBeenCalledWith(user, 100, reply); }); + + it('passes a named dashboard section through and omits smartScopeId unless it is set', async () => { + const { controller, catalogService } = makeController(); + const user = { id: 7 } as never; + + await controller.dashboard(user, { section: 'want-to-read' }); + expect(catalogService.getDashboard).toHaveBeenCalledWith(user, { type: 'want-to-read' }); + + await controller.dashboard(user, { section: 'smart-scope', smartScopeId: 3 }); + expect(catalogService.getDashboard).toHaveBeenCalledWith(user, { type: 'smart-scope', smartScopeId: 3 }); + }); + + it('forwards the single-section refresh route', async () => { + const { controller, catalogService } = makeController(); + const user = { id: 7 } as never; + + await expect(controller.dashboardSection(user, 'want-to-read', {})).resolves.toEqual({ + section: { type: 'want-to-read', smartScopeId: null, books: [] }, + }); + expect(catalogService.getDashboardSection).toHaveBeenCalledWith(user, { type: 'want-to-read' }); + + await controller.dashboardSection(user, 'smart-scope', { smartScopeId: 5 }); + expect(catalogService.getDashboardSection).toHaveBeenCalledWith(user, { type: 'smart-scope', smartScopeId: 5 }); + }); }); diff --git a/server/src/modules/koreader/koreader-catalog.controller.ts b/server/src/modules/koreader/koreader-catalog.controller.ts index d9f1adbad..aef96b52b 100644 --- a/server/src/modules/koreader/koreader-catalog.controller.ts +++ b/server/src/modules/koreader/koreader-catalog.controller.ts @@ -1,6 +1,8 @@ -import { Body, Controller, Get, Header, Headers, Param, ParseIntPipe, Put, Query, Res, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Header, Headers, Param, ParseEnumPipe, ParseIntPipe, Put, Query, Res, UseGuards } from '@nestjs/common'; import type { FastifyReply } from 'fastify'; +import { KOREADER_DASHBOARD_SECTION_TYPE } from '@bookorbit/types'; +import type { KoreaderDashboardSectionType } from '@bookorbit/types'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { Public } from '../../common/decorators/public.decorator'; import type { RequestUser } from '../../common/types/request-user'; @@ -9,6 +11,8 @@ import { KoreaderCatalogService } from './koreader-catalog.service'; import { KoreaderCatalogBookDetailQueryDto, KoreaderCatalogBooksQueryDto, + KoreaderCatalogDashboardQueryDto, + KoreaderCatalogDashboardSectionQueryDto, KoreaderCatalogManifestQueryDto, KoreaderCatalogSectionQueryDto, KoreaderCatalogSetRatingDto, @@ -29,8 +33,9 @@ export class KoreaderCatalogController { @Get('dashboard') @Header('Cache-Control', 'private, max-age=30') - dashboard(@CurrentUser() user: RequestUser) { - return this.catalogService.getDashboard(user); + dashboard(@CurrentUser() user: RequestUser, @Query() query: KoreaderCatalogDashboardQueryDto) { + const section = query.section ? { type: query.section, ...(query.smartScopeId ? { smartScopeId: query.smartScopeId } : {}) } : undefined; + return this.catalogService.getDashboard(user, section); } @Get('dashboard/discover') @@ -39,6 +44,16 @@ export class KoreaderCatalogController { return this.catalogService.getDiscover(user); } + @Get('dashboard/sections/:type') + @Header('Cache-Control', 'no-store') + dashboardSection( + @CurrentUser() user: RequestUser, + @Param('type', new ParseEnumPipe(KOREADER_DASHBOARD_SECTION_TYPE)) type: KoreaderDashboardSectionType, + @Query() query: KoreaderCatalogDashboardSectionQueryDto, + ) { + return this.catalogService.getDashboardSection(user, { type, ...(query.smartScopeId ? { smartScopeId: query.smartScopeId } : {}) }); + } + @Get('sections/:section') @Header('Cache-Control', 'private, max-age=30') sections(@CurrentUser() user: RequestUser, @Param('section') section: string, @Query() query: KoreaderCatalogSectionQueryDto) { diff --git a/server/src/modules/koreader/koreader-catalog.service.test.ts b/server/src/modules/koreader/koreader-catalog.service.test.ts index 434e60fbb..af6605b92 100644 --- a/server/src/modules/koreader/koreader-catalog.service.test.ts +++ b/server/src/modules/koreader/koreader-catalog.service.test.ts @@ -141,6 +141,10 @@ function makeService( findByBookIds: vi.fn().mockResolvedValue(new Map([[10, { status: 'reading' }]])), setManual: vi.fn().mockResolvedValue(undefined), }; + const dashboardService = { + getScrollerBookIds: vi.fn().mockResolvedValue([11, 10]), + getSmartScopeBookIds: vi.fn().mockResolvedValue([10]), + }; const dashboardWidgetService = { getReadingGoal: vi.fn().mockResolvedValue({ goalBooks: 24, completedBooks: 6, year: 2026 }), getReadingStreak: vi.fn().mockResolvedValue({ currentStreak: 4, longestStreak: 9, lastSevenDays: [true, false, true, true, true, false, true] }), @@ -216,6 +220,7 @@ function makeService( bookService as never, bookReadService as never, userBookStatusService as never, + dashboardService as never, dashboardWidgetService as never, recommendationService as never, { isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never, @@ -233,6 +238,7 @@ function makeService( bookService, bookReadService, userBookStatusService, + dashboardService, dashboardWidgetService, recommendationService, pluginService, @@ -340,6 +346,96 @@ describe('KoreaderCatalogService', () => { expect(dashboardWidgetService.getHighlightOfTheDay).toHaveBeenCalledWith(user); }); + function makeEntry(id: number, title: string) { + return { + id, + title, + folderPath: `/books/${id}`, + addedAt: new Date('2026-01-05T00:00:00.000Z'), + updatedAt: new Date('2026-02-05T00:00:00.000Z'), + description: null, + seriesId: null, + seriesName: null, + seriesIndex: null, + language: 'en', + publisher: 'Ace', + isbn13: null, + hasCover: true, + authors: ['Someone'], + files: [{ id: id * 10, format: 'epub' }], + }; + } + + it('serves a configured section instead of the legacy discover row', async () => { + const { service, opdsBookService, dashboardService } = makeService(); + const user = makeUser({ id: 7 }); + opdsBookService.getBooksPage.mockResolvedValue({ total: 0, entries: [] }); + + const dashboard = await service.getDashboard(user, { type: 'want-to-read' }); + + expect(dashboardService.getScrollerBookIds).toHaveBeenCalledWith('want-to-read', user, 10); + expect(opdsBookService.getRandomBooks).not.toHaveBeenCalled(); + expect(dashboard.discover).toEqual([]); + expect(dashboard.section).toEqual({ type: 'want-to-read', smartScopeId: null, books: [] }); + }); + + it('omits the section field entirely when no section is named', async () => { + const { service, opdsBookService } = makeService(); + const user = makeUser({ id: 7 }); + opdsBookService.getBooksPage.mockResolvedValue({ total: 0, entries: [] }); + opdsBookService.getRandomBooks.mockResolvedValueOnce([]); + + const dashboard = await service.getDashboard(user); + + expect(dashboard.section).toBeUndefined(); + }); + + it('preserves the order the section selected its books in', async () => { + const { service, opdsBookService, dashboardService } = makeService(); + const user = makeUser({ id: 7 }); + dashboardService.getScrollerBookIds.mockResolvedValueOnce([11, 10]); + opdsBookService.getBooksPage.mockResolvedValueOnce({ total: 2, entries: [makeEntry(10, 'Dune'), makeEntry(11, 'Dune Messiah')] }); + + const { section } = await service.getDashboardSection(user, { type: 'up-next-in-series' }); + + expect(section.books.map((book) => book.id)).toEqual([11, 10]); + }); + + it('routes a smart-scope section through the smart scope selection', async () => { + const { service, opdsBookService, dashboardService } = makeService(); + const user = makeUser({ id: 7 }); + opdsBookService.getBooksPage.mockResolvedValue({ total: 0, entries: [] }); + + const { section } = await service.getDashboardSection(user, { type: 'smart-scope', smartScopeId: 3 }); + + expect(dashboardService.getSmartScopeBookIds).toHaveBeenCalledWith(3, user, 10); + expect(dashboardService.getScrollerBookIds).not.toHaveBeenCalled(); + expect(section.smartScopeId).toBe(3); + }); + + it('keeps the random section on the original discover implementation', async () => { + const { service, opdsBookService, dashboardService } = makeService(); + const user = makeUser({ id: 7 }); + opdsBookService.getRandomBooks.mockResolvedValueOnce([makeEntry(22, 'Neuromancer')]); + + const { section } = await service.getDashboardSection(user, { type: 'random' }); + + expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 10, false, user.contentFilters); + expect(dashboardService.getScrollerBookIds).not.toHaveBeenCalled(); + expect(section.books.map((book) => book.id)).toEqual([22]); + }); + + it('makes no books query when the section selects nothing', async () => { + const { service, opdsBookService, dashboardService } = makeService(); + const user = makeUser({ id: 7 }); + dashboardService.getScrollerBookIds.mockResolvedValueOnce([]); + + const { section } = await service.getDashboardSection(user, { type: 'recently-added' }); + + expect(section.books).toEqual([]); + expect(opdsBookService.getBooksPage).not.toHaveBeenCalled(); + }); + it('rerolls discover books via getDiscover', async () => { const { service, opdsBookService } = makeService(); const user = makeUser({ id: 7 }); diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index b1222383d..6e9780ab6 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -12,7 +12,10 @@ import type { KoreaderCatalogBookDetail, KoreaderCatalogBookListItem, KoreaderCatalogDashboardResponse, + KoreaderCatalogDashboardSection, + KoreaderCatalogDashboardSectionResponse, KoreaderCatalogDiscoverResponse, + KoreaderDashboardSectionConfig, KoreaderCatalogEntry, KoreaderCatalogFile, KoreaderCatalogManifestBook, @@ -40,6 +43,7 @@ import { storageConfig } from '../../config/config'; import { BookReadService } from '../book/book-read.service'; import { BookService } from '../book/book.service'; import type { BookDetailDto } from '../book/dto/book-detail.dto'; +import { DashboardService } from '../dashboard/dashboard.service'; import { DashboardWidgetService } from '../dashboard/dashboard-widget.service'; import { fileMimeType } from '../opds/opds-xml.helpers'; import { OpdsBookEntry, OpdsBookService } from '../opds/opds-book.service'; @@ -61,6 +65,7 @@ const CATALOG_BASE = '/api/v1/koreader/plugin/catalog'; const AUTHOR_SERIES_PAGE_SIZE = 60; const DASHBOARD_CONTINUE_READING_LIMIT = 5; const DASHBOARD_DISCOVER_LIMIT = 10; +const DASHBOARD_SECTION_LIMIT = 10; const DETAIL_RELATED_LIMIT = 8; const MANIFEST_DEFAULT_PAGE_SIZE = 100; const MANIFEST_CURSOR_VERSION = 1; @@ -133,6 +138,7 @@ export class KoreaderCatalogService { private readonly bookService: BookService, private readonly bookReadService: BookReadService, private readonly userBookStatusService: UserBookStatusService, + private readonly dashboardService: DashboardService, private readonly dashboardWidgetService: DashboardWidgetService, private readonly recommendationService: RecommendationService, private readonly appSettingsService: AppSettingsService, @@ -145,7 +151,7 @@ export class KoreaderCatalogService { return { sections: ROOT_SECTIONS.map((section) => ({ ...section })) }; } - async getDashboard(user: RequestUser): Promise { + async getDashboard(user: RequestUser, section?: KoreaderDashboardSectionConfig): Promise { const continueReadingQuery = Object.assign(new KoreaderCatalogBooksQueryDto(), { page: 1, size: DASHBOARD_CONTINUE_READING_LIMIT, @@ -153,9 +159,10 @@ export class KoreaderCatalogService { readStatus: 'reading' as const, }); - const [continueReading, discover, readingGoal, readingStreak, highlightOfTheDay, totalBooks] = await Promise.all([ + const [continueReading, discover, dashboardSection, readingGoal, readingStreak, highlightOfTheDay, totalBooks] = await Promise.all([ this.getBooksPage(user, continueReadingQuery), - this.buildDiscover(user), + section ? Promise.resolve([]) : this.buildDiscover(user), + section ? this.buildDashboardSection(user, section) : Promise.resolve(undefined), this.dashboardWidgetService.getReadingGoal(user), this.dashboardWidgetService.getReadingStreak(user), this.dashboardWidgetService.getHighlightOfTheDay(user), @@ -167,9 +174,10 @@ export class KoreaderCatalogService { username: user.username, displayName: user.name || user.username, totalBooks, - sections: ROOT_SECTIONS.map((section) => ({ ...section })), + sections: ROOT_SECTIONS.map((rootSection) => ({ ...rootSection })), continueReading: continueReading.items, discover, + ...(dashboardSection ? { section: dashboardSection } : {}), readingGoal, readingStreak, highlightOfTheDay, @@ -180,6 +188,41 @@ export class KoreaderCatalogService { return { discover: await this.buildDiscover(user) }; } + async getDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise { + return { section: await this.buildDashboardSection(user, section) }; + } + + private async buildDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise { + return { + type: section.type, + smartScopeId: section.smartScopeId ?? null, + books: await this.buildSectionBooks(user, section), + }; + } + + private async buildSectionBooks(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise { + // Random keeps its original implementation so the row and its reroll stay + // byte-for-byte what the Discover row already returned. + if (section.type === 'random') return this.buildDiscover(user); + + const bookIds = + section.type === 'smart-scope' + ? await this.dashboardService.getSmartScopeBookIds(section.smartScopeId, user, DASHBOARD_SECTION_LIMIT) + : await this.dashboardService.getScrollerBookIds(section.type, user, DASHBOARD_SECTION_LIMIT); + + return this.loadBookListItemsByIds(user, bookIds); + } + + // Selection order carries the section's meaning (series position, recency), so + // it is restored after the books query re-sorts by its own criteria. + private async loadBookListItemsByIds(user: RequestUser, bookIds: number[]): Promise { + if (bookIds.length === 0) return []; + const query = Object.assign(new KoreaderCatalogBooksQueryDto(), { page: 1, size: bookIds.length, ids: bookIds }); + const { items } = await this.getBooksPage(user, query); + const byId = new Map(items.map((item) => [item.id, item])); + return bookIds.map((id) => byId.get(id)).filter((item): item is KoreaderCatalogBookListItem => item !== undefined); + } + private async buildDiscover(user: RequestUser): Promise { const entries = await this.opdsBookService.getRandomBooks(user.id, DASHBOARD_DISCOVER_LIMIT, user.isSuperuser, user.contentFilters); if (entries.length === 0) return []; diff --git a/server/src/modules/koreader/koreader-package.service.ts b/server/src/modules/koreader/koreader-package.service.ts index fc302551e..bb51bcde9 100644 --- a/server/src/modules/koreader/koreader-package.service.ts +++ b/server/src/modules/koreader/koreader-package.service.ts @@ -17,7 +17,7 @@ const PROVISION_FILE = 'bookorbit_provision.lua'; // Wire features this server advertises. The plugin selects a new route only // when its name appears here, so a downgraded server transparently returns the // plugin to its legacy path. -const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest']; +const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest', 'catalogDashboardSections']; @Injectable() export class KoreaderPackageService { diff --git a/server/src/modules/koreader/koreader-plugin-source.test.ts b/server/src/modules/koreader/koreader-plugin-source.test.ts index 6f02e70ff..b3dd40069 100644 --- a/server/src/modules/koreader/koreader-plugin-source.test.ts +++ b/server/src/modules/koreader/koreader-plugin-source.test.ts @@ -87,12 +87,12 @@ describe('KOReader plugin update source wiring', () => { expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")'); }); - it('keeps tall dashboard adaptation scoped to measured Discover rows', async () => { + it('keeps tall dashboard adaptation scoped to the measured configurable row', async () => { const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua'); expect(dashboard).toContain('local DASHBOARD_TALL_ASPECT_RATIO = 1.55'); - expect(dashboard).toContain('local DISCOVER_COMPACT_GAP = 6'); - expect(dashboard).toContain('local DISCOVER_MAX_ROWS = 2'); + expect(dashboard).toContain('local SECTION_COMPACT_GAP = 6'); + expect(dashboard).toContain('local SECTION_MAX_ROWS = 2'); expect(dashboard).toContain('local STATS_MIN_BODY_HEIGHT = 56'); expect(dashboard).toContain('function CatalogDashboard:dashboardTallLayout()'); expect(dashboard).toContain('function CatalogDashboard:addDashboardCoverGrid('); @@ -100,14 +100,36 @@ describe('KOReader plugin update source wiring', () => { expect(dashboard).toContain('local card_gap = slots > 1 and math.floor(math.max(0, self.content_w - slots * card_w) / (slots - 1)) or 0'); expect(dashboard).toContain('if slot > 1 then'); expect(dashboard).not.toContain('(slots + 1) * gap'); - expect(dashboard).toContain('discover_slots, discover_card_w, discover_row_h = self:discoverRowMetrics(#discover_books, discover_gap)'); + expect(dashboard).toContain('section_slots, section_card_w, section_row_h = self:sectionRowMetrics(#section_books, section_row_gap)'); expect(dashboard).toContain('if fixedHeight() > avail then'); - expect(dashboard).toContain('discover_rows * discover_row_h + math.max(0, discover_rows - 1) * inner_gap'); - expect(dashboard).toContain('show_discover and self:dashboardTallLayout() and discover_slots > 0'); - expect(dashboard).toContain('local discover_page_size = math.max(1, discover_slots * discover_rows)'); + expect(dashboard).toContain('section_rows * section_row_h + math.max(0, section_rows - 1) * inner_gap'); + expect(dashboard).toContain('show_section and not section_pending and self:dashboardTallLayout() and section_slots > 0'); + expect(dashboard).toContain('local section_page_size = math.max(1, section_slots * section_rows)'); expect(dashboard).not.toContain('catalog_dashboard_max_height'); }); + it('renders the configurable dashboard row from a stored section choice', async () => { + const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua'); + const sections = await readPluginFile('bookorbit_dashboard_sections.lua'); + const api = await readPluginFile('bookorbit_api.lua'); + + // The stored value is a list even though one row is rendered, so adding a + // second configurable row later does not invalidate settings on device. + expect(sections).toContain('DashboardSections.SETTING_KEY = "catalog_dashboard_sections"'); + expect(sections).toContain('DashboardSections.DEFAULT_TYPE = "random"'); + expect(sections).toContain('function DashboardSections.normalize(value)'); + expect(sections).toContain('function DashboardSections.signature(config)'); + + // The parameter is capability-gated: an older server rejects unknown query + // params outright rather than ignoring them. + expect(dashboard).toContain('DashboardSections.CAPABILITY'); + expect(dashboard).toContain('Capabilities.markUnsupported(self.client, DashboardSections.CAPABILITY)'); + expect(dashboard).toContain('function CatalogDashboard:dashboardCacheMatchesSection()'); + expect(dashboard).toContain('function CatalogDashboard:dashboardSectionSupportsReroll(config)'); + expect(api).toContain('function BookOrbitApi:catalogDashboard(section)'); + expect(api).toContain('function BookOrbitApi:catalogDashboardSection(section)'); + }); + it('renders the canonical account reading streak on the device dashboard', async () => { const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua'); const catalogUtil = await readPluginFile('bookorbit_catalog_util.lua'); @@ -354,7 +376,7 @@ describe('KOReader plugin update source wiring', () => { it('rechecks matched local hashes during full-library revalidation', async () => { const sweep = await readPluginFile('bookorbit_sweep.lua'); - expect(sweep).toContain('if ctx.full_recheck then\n queue(md5)\n elseif not ctx.state:getBook(md5) then'); + expect(sweep).toContain('if ctx.full_recheck then\n queue(md5)\n else\n local book = ctx.state:getBook(md5)'); expect(sweep).toContain('for md5 in pairs(ctx.state.books) do\n queue(md5)\n end'); }); @@ -372,6 +394,6 @@ describe('KOReader plugin update source wiring', () => { expect(sweep).toContain('local file_exists = lfs.attributes(file, "mode") == "file"'); expect(sweep).toContain('cand.source = "file"'); expect(sweep).toContain('cand.metadata_ambiguous = false'); - expect(sweep).toContain('if cand.stat_ids and not cand.stats_metadata_ambiguous and ctx.state:getBook(md5) then'); + expect(sweep).toContain('if cand.stat_ids and not cand.stats_metadata_ambiguous then'); }); });