mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(koreader): support configurable dashboard sections in KOReader catalog
Add server endpoints and KOReader plugin settings to render configurable dashboard section rows, such as Smart Scopes or custom scrollers, replacing the hardcoded Discover section. Update the plugin capability checks to fall back gracefully on older server versions.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Syncs your KOReader reading life into BookOrbit:
|
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.
|
- **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.
|
- **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.
|
- **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.
|
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).
|
2. Optional: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
|
||||||
3. Optional: Settings > Dashboard > "Open dashboard on startup".
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -539,14 +539,26 @@ function BookOrbitApi:catalogRoot()
|
|||||||
return self:request("GET", "/koreader/plugin/catalog/root")
|
return self:request("GET", "/koreader/plugin/catalog/root")
|
||||||
end
|
end
|
||||||
|
|
||||||
function BookOrbitApi:catalogDashboard()
|
-- Naming a section is opt-in: without it the server returns the pre-section
|
||||||
return self:request("GET", "/koreader/plugin/catalog/dashboard")
|
-- 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
|
end
|
||||||
|
|
||||||
function BookOrbitApi:catalogDiscover()
|
function BookOrbitApi:catalogDiscover()
|
||||||
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
|
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
|
||||||
end
|
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)
|
function BookOrbitApi:catalogSection(section, params)
|
||||||
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
|
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ Dashboard mixin for the BookOrbit catalog browser.
|
|||||||
|
|
||||||
Builds and renders the single-page dashboard: a summary stats band, a
|
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
|
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
|
all in-progress books), one configurable book row and a compact Browse list,
|
||||||
the offline cache and the Discover reroll. Installed onto the catalog
|
including the offline cache and the Discover reroll. Installed onto the catalog
|
||||||
controller as regular methods.
|
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
|
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")
|
local Blitbuffer = require("ffi/blitbuffer")
|
||||||
@@ -30,8 +34,10 @@ local VerticalSpan = require("ui/widget/verticalspan")
|
|||||||
local T = require("ffi/util").template
|
local T = require("ffi/util").template
|
||||||
local _ = require("gettext")
|
local _ = require("gettext")
|
||||||
|
|
||||||
|
local Capabilities = require("bookorbit_capabilities")
|
||||||
local CatalogUtil = require("bookorbit_catalog_util")
|
local CatalogUtil = require("bookorbit_catalog_util")
|
||||||
local CatalogWidgets = require("bookorbit_catalog_widgets")
|
local CatalogWidgets = require("bookorbit_catalog_widgets")
|
||||||
|
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||||
local BookOrbitStatsReader = require("bookorbit_stats_reader")
|
local BookOrbitStatsReader = require("bookorbit_stats_reader")
|
||||||
|
|
||||||
local formatDuration = CatalogUtil.formatDuration
|
local formatDuration = CatalogUtil.formatDuration
|
||||||
@@ -46,11 +52,12 @@ local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
|
|||||||
-- How long the local reading-stats summary is reused before re-querying
|
-- How long the local reading-stats summary is reused before re-querying
|
||||||
-- statistics.sqlite3 (the dashboard re-renders on every row page turn).
|
-- statistics.sqlite3 (the dashboard re-renders on every row page turn).
|
||||||
local STATS_CACHE_TTL = 120
|
local STATS_CACHE_TTL = 120
|
||||||
local DISCOVER_TARGET_SLOTS = 5
|
local SECTION_TARGET_SLOTS = 5
|
||||||
local DISCOVER_COMPACT_GAP = 6
|
local SECTION_COMPACT_GAP = 6
|
||||||
local DASHBOARD_TALL_ASPECT_RATIO = 1.55
|
local DASHBOARD_TALL_ASPECT_RATIO = 1.55
|
||||||
local DISCOVER_MAX_ROWS = 2
|
local SECTION_MAX_ROWS = 2
|
||||||
local STATS_MIN_BODY_HEIGHT = 56
|
local STATS_MIN_BODY_HEIGHT = 56
|
||||||
|
local SECTION_PAGE_ID = "section"
|
||||||
|
|
||||||
local CatalogDashboard = {}
|
local CatalogDashboard = {}
|
||||||
|
|
||||||
@@ -66,6 +73,46 @@ end
|
|||||||
function CatalogDashboard:cacheDashboard(body)
|
function CatalogDashboard:cacheDashboard(body)
|
||||||
if type(body) ~= "table" then return end
|
if type(body) ~= "table" then return end
|
||||||
self:persistSetting("catalog_dashboard_cache", body)
|
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
|
end
|
||||||
|
|
||||||
function CatalogDashboard.dashboardItems()
|
function CatalogDashboard.dashboardItems()
|
||||||
@@ -96,16 +143,26 @@ function CatalogDashboard:dashboardContext(dashboard, opts)
|
|||||||
stale = opts.stale == true,
|
stale = opts.stale == true,
|
||||||
unavailable = opts.unavailable == true,
|
unavailable = opts.unavailable == true,
|
||||||
loading = opts.loading == true,
|
loading = opts.loading == true,
|
||||||
|
section_stale = opts.section_stale == true,
|
||||||
}
|
}
|
||||||
end
|
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
|
-- What the catalog widget opens on, built without touching the network: the
|
||||||
-- cached dashboard when there is one, otherwise a placeholder the pending
|
-- cached dashboard when there is one, otherwise a placeholder the pending
|
||||||
-- refresh replaces in place.
|
-- refresh replaces in place.
|
||||||
function CatalogDashboard:initialDashboardContext()
|
function CatalogDashboard:initialDashboardContext()
|
||||||
local cached = self:dashboardCache()
|
local cached = self:dashboardCache()
|
||||||
if cached then
|
if cached then
|
||||||
return self:dashboardContext(cached, { stale = true })
|
return self:cachedDashboardContext(cached)
|
||||||
end
|
end
|
||||||
if self:shouldRefreshDashboardOnOpen() then
|
if self:shouldRefreshDashboardOnOpen() then
|
||||||
return self:dashboardContext(nil, { stale = true, loading = true })
|
return self:dashboardContext(nil, { stale = true, loading = true })
|
||||||
@@ -123,18 +180,39 @@ function CatalogDashboard:shouldRefreshDashboardOnOpen()
|
|||||||
return true
|
return true
|
||||||
end
|
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)
|
function CatalogDashboard:dashboardRoot(opts)
|
||||||
local function requestIsCurrent()
|
local function requestIsCurrent()
|
||||||
return not opts or not opts.is_current or opts.is_current()
|
return not opts or not opts.is_current or opts.is_current()
|
||||||
end
|
end
|
||||||
local cached = self:dashboardCache()
|
local cached = self:dashboardCache()
|
||||||
if self.prefer_cached_dashboard and cached and not NetworkMgr:isConnected() then
|
if self.prefer_cached_dashboard and cached and not NetworkMgr:isConnected() then
|
||||||
return self:dashboardContext(cached, { stale = true })
|
return self:cachedDashboardContext(cached)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local section = self:dashboardSectionRequest()
|
||||||
local body, err = self:fetch(_("Loading dashboard..."), function()
|
local body, err = self:fetch(_("Loading dashboard..."), function()
|
||||||
return self.client:catalogDashboard()
|
return self.client:catalogDashboard(section)
|
||||||
end, opts)
|
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
|
if body and body.continueReading then
|
||||||
self:cacheDashboard(body)
|
self:cacheDashboard(body)
|
||||||
return self:dashboardContext(body)
|
return self:dashboardContext(body)
|
||||||
@@ -151,7 +229,7 @@ function CatalogDashboard:dashboardRoot(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if cached then
|
if cached then
|
||||||
return self:dashboardContext(cached, { stale = true })
|
return self:cachedDashboardContext(cached)
|
||||||
end
|
end
|
||||||
|
|
||||||
if err ~= "cancelled" and requestIsCurrent() then
|
if err ~= "cancelled" and requestIsCurrent() then
|
||||||
@@ -181,15 +259,15 @@ function CatalogDashboard:loadDashboardRoot(replace, opts)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- All books rendered on the dashboard (continue reading + discover), used for
|
-- All books rendered on the dashboard (continue reading + the configurable
|
||||||
-- thumbnail prefetching and cover-cache eviction.
|
-- row), used for thumbnail prefetching and cover-cache eviction.
|
||||||
function CatalogDashboard.dashboardBooks(dashboard)
|
function CatalogDashboard.dashboardBooks(dashboard)
|
||||||
dashboard = dashboard or {}
|
dashboard = dashboard or {}
|
||||||
local books = {}
|
local books = {}
|
||||||
for _, book in ipairs(dashboard.continueReading or {}) do
|
for _, book in ipairs(dashboard.continueReading or {}) do
|
||||||
table.insert(books, book)
|
table.insert(books, book)
|
||||||
end
|
end
|
||||||
for _, book in ipairs(dashboard.discover or {}) do
|
for _, book in ipairs(CatalogDashboard.dashboardSectionBooks(dashboard)) do
|
||||||
table.insert(books, book)
|
table.insert(books, book)
|
||||||
end
|
end
|
||||||
return books
|
return books
|
||||||
@@ -290,6 +368,12 @@ function CatalogDashboard:addDashboardHeader(text, controls)
|
|||||||
end
|
end
|
||||||
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()
|
function CatalogDashboard:buildDashboardRerollButton()
|
||||||
local size = Screen:scaleBySize(24)
|
local size = Screen:scaleBySize(24)
|
||||||
return BookOrbitDashboardIconButton:new{
|
return BookOrbitDashboardIconButton:new{
|
||||||
@@ -407,8 +491,8 @@ function CatalogDashboard:addDashboardHeroRow(books, height, slots, page)
|
|||||||
self.dash_used = self.dash_used + height
|
self.dash_used = self.dash_used + height
|
||||||
end
|
end
|
||||||
|
|
||||||
function CatalogDashboard:discoverRowMetrics(count, gap)
|
function CatalogDashboard:sectionRowMetrics(count, gap)
|
||||||
local slots = math.min(DISCOVER_TARGET_SLOTS, count)
|
local slots = math.min(SECTION_TARGET_SLOTS, count)
|
||||||
gap = gap or self.dash_inner_gap
|
gap = gap or self.dash_inner_gap
|
||||||
local min_card_w = Screen:scaleBySize(72)
|
local min_card_w = Screen:scaleBySize(72)
|
||||||
while slots > 1
|
while slots > 1
|
||||||
@@ -626,7 +710,12 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
|
|||||||
local context = self.current_context or {}
|
local context = self.current_context or {}
|
||||||
local dashboard = context.dashboard
|
local dashboard = context.dashboard
|
||||||
local continue_books = dashboard and dashboard.continueReading or {}
|
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 action_entries = self:dashboardActionEntries()
|
||||||
local summary = self:dashboardStatsSummary() or { today_seconds = 0, week_seconds = 0, streak_days = 0 }
|
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 stats_h = stats_widget and stats_widget:getSize().h or 0
|
||||||
|
|
||||||
local show_stats = stats_widget ~= nil
|
local show_stats = stats_widget ~= nil
|
||||||
local show_discover = #discover_books > 0
|
local show_section = #section_books > 0 or section_pending
|
||||||
local discover_gap = inner_gap
|
local section_row_gap = inner_gap
|
||||||
local discover_slots = 0
|
local section_slots = 0
|
||||||
local discover_card_w = 0
|
local section_card_w = 0
|
||||||
local discover_row_h = 0
|
local section_row_h = 0
|
||||||
local discover_rows = 1
|
local section_rows = 1
|
||||||
|
|
||||||
local function updateDiscoverMetrics()
|
local function updateSectionMetrics()
|
||||||
if show_discover then
|
if show_section and #section_books > 0 then
|
||||||
discover_slots, discover_card_w, discover_row_h = self:discoverRowMetrics(#discover_books, discover_gap)
|
section_slots, section_card_w, section_row_h = self:sectionRowMetrics(#section_books, section_row_gap)
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
updateDiscoverMetrics()
|
updateSectionMetrics()
|
||||||
|
|
||||||
local function discoverGridHeight()
|
local function sectionGridHeight()
|
||||||
if not show_discover then return 0 end
|
if not show_section then return 0 end
|
||||||
return discover_rows * discover_row_h + math.max(0, discover_rows - 1) * inner_gap
|
if section_pending then return empty_h end
|
||||||
|
return section_rows * section_row_h + math.max(0, section_rows - 1) * inner_gap
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Fixed blocks are measured up front. If the dashboard gets too tight, the
|
-- 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 function fixedHeight()
|
||||||
local fixed = top_gap
|
local fixed = top_gap
|
||||||
+ (show_stats and (stats_h + stats_gap) or 0)
|
+ (show_stats and (stats_h + stats_gap) or 0)
|
||||||
+ header_h + inner_gap
|
+ header_h + inner_gap
|
||||||
+ (has_continue and hero_h or empty_h)
|
+ (has_continue and hero_h or empty_h)
|
||||||
+ section_gap
|
+ 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
|
+ header_h + inner_gap + browse_rows * browse_row_h
|
||||||
+ inner_gap
|
+ inner_gap
|
||||||
return fixed
|
return fixed
|
||||||
@@ -688,35 +778,35 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
|
|||||||
if show_stats and fixedHeight() > avail then
|
if show_stats and fixedHeight() > avail then
|
||||||
show_stats = false
|
show_stats = false
|
||||||
end
|
end
|
||||||
if show_discover and fixedHeight() > avail then
|
if show_section and fixedHeight() > avail then
|
||||||
show_discover = false
|
show_section = false
|
||||||
updateDiscoverMetrics()
|
updateSectionMetrics()
|
||||||
end
|
end
|
||||||
if show_discover then
|
if show_section and not section_pending then
|
||||||
local compact_gap = px(DISCOVER_COMPACT_GAP)
|
local compact_gap = px(SECTION_COMPACT_GAP)
|
||||||
if compact_gap < discover_gap then
|
if compact_gap < section_row_gap then
|
||||||
local previous_gap = discover_gap
|
local previous_gap = section_row_gap
|
||||||
local previous_slots, previous_card_w, previous_row_h = discover_slots, discover_card_w, discover_row_h
|
local previous_slots, previous_card_w, previous_row_h = section_slots, section_card_w, section_row_h
|
||||||
discover_gap = compact_gap
|
section_row_gap = compact_gap
|
||||||
updateDiscoverMetrics()
|
updateSectionMetrics()
|
||||||
if fixedHeight() > avail then
|
if fixedHeight() > avail then
|
||||||
discover_gap = previous_gap
|
section_row_gap = previous_gap
|
||||||
discover_slots, discover_card_w, discover_row_h = previous_slots, previous_card_w, previous_row_h
|
section_slots, section_card_w, section_row_h = previous_slots, previous_card_w, previous_row_h
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if show_discover and self:dashboardTallLayout() and discover_slots > 0 then
|
if show_section and not section_pending and self:dashboardTallLayout() and section_slots > 0 then
|
||||||
local full_rows = math.floor(#discover_books / discover_slots)
|
local full_rows = math.floor(#section_books / section_slots)
|
||||||
local remainder = #discover_books % discover_slots
|
local remainder = #section_books % section_slots
|
||||||
local book_rows = full_rows
|
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
|
book_rows = book_rows + 1
|
||||||
end
|
end
|
||||||
local max_rows = math.min(DISCOVER_MAX_ROWS, math.max(1, book_rows))
|
local max_rows = math.min(SECTION_MAX_ROWS, math.max(1, book_rows))
|
||||||
while discover_rows < max_rows do
|
while section_rows < max_rows do
|
||||||
discover_rows = discover_rows + 1
|
section_rows = section_rows + 1
|
||||||
if fixedHeight() > avail then
|
if fixedHeight() > avail then
|
||||||
discover_rows = discover_rows - 1
|
section_rows = section_rows - 1
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -765,23 +855,32 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
|
|||||||
end
|
end
|
||||||
self:addDashboardSpacer(section_gap)
|
self:addDashboardSpacer(section_gap)
|
||||||
|
|
||||||
if show_discover then
|
if show_section then
|
||||||
discover_slots = math.min(discover_slots, #discover_books)
|
local section_controls = {}
|
||||||
local discover_page_size = math.max(1, discover_slots * discover_rows)
|
local section_page = 1
|
||||||
local discover_pages = math.max(1, math.ceil(#discover_books / discover_page_size))
|
if not section_pending then
|
||||||
local discover_page = self:dashboardPage("discover", discover_pages)
|
section_slots = math.min(section_slots, #section_books)
|
||||||
local discover_controls = {}
|
local section_page_size = math.max(1, section_slots * section_rows)
|
||||||
if discover_pages > 1 then
|
local section_pages = math.max(1, math.ceil(#section_books / section_page_size))
|
||||||
local prev_button, next_button = self:buildDashboardHeaderNav("discover", discover_page, discover_pages)
|
section_page = self:dashboardPage(SECTION_PAGE_ID, section_pages)
|
||||||
table.insert(discover_controls, prev_button)
|
if section_pages > 1 then
|
||||||
table.insert(discover_controls, next_button)
|
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
|
||||||
table.insert(discover_controls, self:buildDashboardRerollButton())
|
|
||||||
|
|
||||||
self:addDashboardHeader(_("Discover"), discover_controls)
|
self:addDashboardHeader(DashboardSections.headerText(section_config), section_controls)
|
||||||
self:addDashboardSpacer(inner_gap)
|
self:addDashboardSpacer(inner_gap)
|
||||||
self:addDashboardCoverGrid("discover", discover_books, discover_row_h, false, false,
|
if section_pending then
|
||||||
discover_slots, discover_card_w, discover_page, discover_rows)
|
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:addDashboardSpacer(section_gap)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -794,8 +893,27 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
|
|||||||
self:finishCustomUpdate(old_dimen, select_number)
|
self:finishCustomUpdate(old_dimen, select_number)
|
||||||
end
|
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
|
-- 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()
|
function CatalogDashboard:rerollDiscover()
|
||||||
if not self:dashboardMode() then return end
|
if not self:dashboardMode() then return end
|
||||||
self:runConnected(function()
|
self:runConnected(function()
|
||||||
@@ -803,21 +921,32 @@ function CatalogDashboard:rerollDiscover()
|
|||||||
return self.client:catalogDiscover()
|
return self.client:catalogDiscover()
|
||||||
end)
|
end)
|
||||||
if body and body.discover then
|
if body and body.discover then
|
||||||
local context = self.current_context
|
self:applyDashboardSectionBooks(self.current_context, body.discover)
|
||||||
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
|
|
||||||
elseif err and err ~= "cancelled" then
|
elseif err and err ~= "cancelled" then
|
||||||
self:showServerError(err)
|
self:showServerError(err)
|
||||||
end
|
end
|
||||||
end)
|
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)
|
function CatalogDashboard.install(Catalog)
|
||||||
for name, fn in pairs(CatalogDashboard) do
|
for name, fn in pairs(CatalogDashboard) do
|
||||||
if name ~= "install" then
|
if name ~= "install" then
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,6 +21,7 @@ local _ = require("gettext")
|
|||||||
local BookOrbitApi = require("bookorbit_api")
|
local BookOrbitApi = require("bookorbit_api")
|
||||||
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
|
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
|
||||||
local BookOrbitSweep = require("bookorbit_sweep")
|
local BookOrbitSweep = require("bookorbit_sweep")
|
||||||
|
local DashboardSections = require("bookorbit_dashboard_sections")
|
||||||
|
|
||||||
-- Assigned from the plugin class on install so menu labels can name the
|
-- Assigned from the plugin class on install so menu labels can name the
|
||||||
-- shared sync strategies.
|
-- shared sync strategies.
|
||||||
@@ -286,7 +287,115 @@ function MainMenu:testConnection()
|
|||||||
end)
|
end)
|
||||||
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 {
|
return {
|
||||||
{
|
{
|
||||||
text_func = function()
|
text_func = function()
|
||||||
@@ -294,6 +403,12 @@ function MainMenu:dashboardSettingsMenu()
|
|||||||
end,
|
end,
|
||||||
sub_item_table = self:catalogAutoOpenMenu(),
|
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
|
end
|
||||||
|
|
||||||
@@ -385,7 +500,7 @@ function MainMenu:settingsMenu(has_open_book, opts)
|
|||||||
local items = {
|
local items = {
|
||||||
{
|
{
|
||||||
text = _("Dashboard"),
|
text = _("Dashboard"),
|
||||||
sub_item_table = self:dashboardSettingsMenu(),
|
sub_item_table = self:dashboardSettingsMenu(opts.catalog),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text = _("Sync"),
|
text = _("Sync"),
|
||||||
@@ -517,7 +632,7 @@ function MainMenu:dashboardMenuItems(catalog)
|
|||||||
id = "settings",
|
id = "settings",
|
||||||
text = _("Settings"),
|
text = _("Settings"),
|
||||||
separator = item.separator,
|
separator = item.separator,
|
||||||
sub_item_table = self:settingsMenu(false, { include_plugin = false }),
|
sub_item_table = self:settingsMenu(false, { include_plugin = false, catalog = catalog }),
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
table.insert(items, item)
|
table.insert(items, item)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
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")
|
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, "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")
|
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, "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")
|
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_settings = findItem(dashboard_items, "Settings")
|
||||||
local dashboard_dashboard_settings = findItem(dashboard_settings.sub_item_table, "Dashboard")
|
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, "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")
|
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, "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")
|
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "dashboard sync settings has offline skip toggle")
|
||||||
|
|||||||
@@ -270,6 +270,32 @@ export interface KoreaderCatalogSectionResponse {
|
|||||||
query?: string | null;
|
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<KoreaderDashboardSectionType>;
|
||||||
|
|
||||||
|
// 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 {
|
export interface KoreaderCatalogDashboardResponse {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -277,7 +303,11 @@ export interface KoreaderCatalogDashboardResponse {
|
|||||||
totalBooks: number;
|
totalBooks: number;
|
||||||
sections: KoreaderCatalogEntry[];
|
sections: KoreaderCatalogEntry[];
|
||||||
continueReading: KoreaderCatalogBookListItem[];
|
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[];
|
discover: KoreaderCatalogBookListItem[];
|
||||||
|
section?: KoreaderCatalogDashboardSection;
|
||||||
readingGoal: ReadingGoalWidgetData;
|
readingGoal: ReadingGoalWidgetData;
|
||||||
readingStreak: ReadingStreakWidgetData;
|
readingStreak: ReadingStreakWidgetData;
|
||||||
highlightOfTheDay: HighlightOfTheDayWidgetData | null;
|
highlightOfTheDay: HighlightOfTheDayWidgetData | null;
|
||||||
@@ -287,6 +317,10 @@ export interface KoreaderCatalogDiscoverResponse {
|
|||||||
discover: KoreaderCatalogBookListItem[];
|
discover: KoreaderCatalogBookListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KoreaderCatalogDashboardSectionResponse {
|
||||||
|
section: KoreaderCatalogDashboardSection;
|
||||||
|
}
|
||||||
|
|
||||||
// Everything a bulk download needs per downloadable file, so no per-book detail
|
// 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
|
// 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
|
// download route streams, so it doubles as the restart-validation checksum and
|
||||||
@@ -325,7 +359,7 @@ export interface KoreaderCatalogManifestPage {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KoreaderPluginCapability = "catalogBulkManifest";
|
export type KoreaderPluginCapability = "catalogBulkManifest" | "catalogDashboardSections";
|
||||||
|
|
||||||
export interface KoreaderPluginVersionInfo {
|
export interface KoreaderPluginVersionInfo {
|
||||||
pluginVersion: string;
|
pluginVersion: string;
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ import { DashboardWidgetService } from './dashboard-widget.service';
|
|||||||
imports: [BookModule, LibraryModule, SmartScopeModule],
|
imports: [BookModule, LibraryModule, SmartScopeModule],
|
||||||
controllers: [DashboardController],
|
controllers: [DashboardController],
|
||||||
providers: [DashboardService, DashboardRepository, DashboardWidgetService, DashboardWidgetRepository],
|
providers: [DashboardService, DashboardRepository, DashboardWidgetService, DashboardWidgetRepository],
|
||||||
exports: [DashboardWidgetService],
|
exports: [DashboardService, DashboardWidgetService],
|
||||||
})
|
})
|
||||||
export class DashboardModule {}
|
export class DashboardModule {}
|
||||||
|
|||||||
@@ -35,39 +35,55 @@ export class DashboardService {
|
|||||||
const clampedLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
const clampedLimit = Math.min(Math.max(1, limit), MAX_LIMIT);
|
||||||
|
|
||||||
if (type === ScrollerType.SMART_SCOPE) {
|
if (type === ScrollerType.SMART_SCOPE) {
|
||||||
if (!smartScopeId || smartScopeId <= 0) {
|
const result = await this.smartScopeService.executeSmartScope(this.assertSmartScopeId(smartScopeId), user, 0, clampedLimit);
|
||||||
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);
|
|
||||||
return result.items;
|
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<ScrollerType, 'smart-scope'>, user: RequestUser, limit: number): Promise<number[]> {
|
||||||
|
return this.findScrollerBookIds(type, user, Math.min(Math.max(1, limit), MAX_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSmartScopeBookIds(smartScopeId: number | undefined, user: RequestUser, limit: number): Promise<number[]> {
|
||||||
|
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<ScrollerType, 'smart-scope'>, user: RequestUser, clampedLimit: number): Promise<number[]> {
|
||||||
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
||||||
if (accessibleLibraryIds.length === 0) return [];
|
if (accessibleLibraryIds.length === 0) return [];
|
||||||
|
|
||||||
const contentFilters = user.isSuperuser ? undefined : user.contentFilters;
|
const contentFilters = user.isSuperuser ? undefined : user.contentFilters;
|
||||||
let bookIds: number[];
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ScrollerType.RECENTLY_ADDED:
|
case ScrollerType.RECENTLY_ADDED:
|
||||||
bookIds = await this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters);
|
return this.dashboardRepo.findRecentlyAddedBookIds(accessibleLibraryIds, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
case ScrollerType.CONTINUE_READING:
|
case ScrollerType.CONTINUE_READING:
|
||||||
bookIds = await this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
return this.dashboardRepo.findContinueReadingBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
case ScrollerType.CONTINUE_LISTENING:
|
case ScrollerType.CONTINUE_LISTENING:
|
||||||
bookIds = await this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
return this.dashboardRepo.findContinueListeningBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
case ScrollerType.WANT_TO_READ:
|
case ScrollerType.WANT_TO_READ:
|
||||||
bookIds = await this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
return this.dashboardRepo.findWantToReadBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
case ScrollerType.UP_NEXT_IN_SERIES:
|
case ScrollerType.UP_NEXT_IN_SERIES:
|
||||||
bookIds = await this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
return this.dashboardRepo.findUpNextInSeriesBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
case ScrollerType.RANDOM:
|
case ScrollerType.RANDOM:
|
||||||
bookIds = await this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
return this.dashboardRepo.findRandomBookIds(accessibleLibraryIds, user.id, clampedLimit, contentFilters);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.loadCardsByIds(bookIds, user.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
|
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 {
|
import type {
|
||||||
KoreaderCatalogReadStatusFilter,
|
KoreaderCatalogReadStatusFilter,
|
||||||
KoreaderCatalogSettableReadStatus,
|
KoreaderCatalogSettableReadStatus,
|
||||||
KoreaderCatalogSort,
|
KoreaderCatalogSort,
|
||||||
KoreaderCatalogSortOrder,
|
KoreaderCatalogSortOrder,
|
||||||
|
KoreaderDashboardSectionType,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
import { KOREADER_DEVICE_ID_REGEX } from './koreader-device-param.dto';
|
import { KOREADER_DEVICE_ID_REGEX } from './koreader-device-param.dto';
|
||||||
|
|
||||||
@@ -197,6 +199,28 @@ export class KoreaderCatalogManifestQueryDto {
|
|||||||
seriesId?: number;
|
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 {
|
export class KoreaderCatalogSectionQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function makeController() {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ items: [], total: 0, page: 1, size: 20, hasNext: false, hasPrevious: false, nextUrl: null, previousUrl: null }),
|
.mockResolvedValue({ items: [], total: 0, page: 1, size: 20, hasNext: false, hasPrevious: false, nextUrl: null, previousUrl: null }),
|
||||||
getBookDetail: vi.fn().mockResolvedValue({ id: 10 }),
|
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 }),
|
getBulkManifest: vi.fn().mockResolvedValue({ items: [], hasNext: false, nextCursor: null, manifestVersion: 'lib-v1', restartRequired: false }),
|
||||||
setReadStatus: vi.fn().mockResolvedValue({ readStatus: 'reading' }),
|
setReadStatus: vi.fn().mockResolvedValue({ readStatus: 'reading' }),
|
||||||
setRating: vi.fn().mockResolvedValue({ rating: 4 }),
|
setRating: vi.fn().mockResolvedValue({ rating: 4 }),
|
||||||
@@ -37,7 +38,7 @@ describe('KoreaderCatalogController', () => {
|
|||||||
const bookDetailQuery = { deviceId: 'device-1' };
|
const bookDetailQuery = { deviceId: 'device-1' };
|
||||||
|
|
||||||
expect(controller.root()).toEqual({ sections: [] });
|
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.sections(user, 'libraries', sectionQuery)).resolves.toEqual({ section: 'libraries', items: [] });
|
||||||
await expect(controller.books(user, query)).resolves.toEqual(expect.objectContaining({ total: 0 }));
|
await expect(controller.books(user, query)).resolves.toEqual(expect.objectContaining({ total: 0 }));
|
||||||
await expect(controller.bookDetail(user, 10, bookDetailQuery)).resolves.toEqual({ id: 10 });
|
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.thumbnail(user, 10, reply, '"etag"');
|
||||||
await controller.download(user, 100, reply);
|
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.getSectionEntries).toHaveBeenCalledWith(user, 'libraries', sectionQuery);
|
||||||
expect(catalogService.getBooksPage).toHaveBeenCalledWith(user, query);
|
expect(catalogService.getBooksPage).toHaveBeenCalledWith(user, query);
|
||||||
expect(catalogService.getBookDetail).toHaveBeenCalledWith(user, 10, 'device-1');
|
expect(catalogService.getBookDetail).toHaveBeenCalledWith(user, 10, 'device-1');
|
||||||
@@ -58,4 +59,28 @@ describe('KoreaderCatalogController', () => {
|
|||||||
expect(catalogService.streamThumbnail).toHaveBeenCalledWith(user, 10, reply, '"etag"');
|
expect(catalogService.streamThumbnail).toHaveBeenCalledWith(user, 10, reply, '"etag"');
|
||||||
expect(catalogService.streamFile).toHaveBeenCalledWith(user, 100, reply);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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 { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
@@ -9,6 +11,8 @@ import { KoreaderCatalogService } from './koreader-catalog.service';
|
|||||||
import {
|
import {
|
||||||
KoreaderCatalogBookDetailQueryDto,
|
KoreaderCatalogBookDetailQueryDto,
|
||||||
KoreaderCatalogBooksQueryDto,
|
KoreaderCatalogBooksQueryDto,
|
||||||
|
KoreaderCatalogDashboardQueryDto,
|
||||||
|
KoreaderCatalogDashboardSectionQueryDto,
|
||||||
KoreaderCatalogManifestQueryDto,
|
KoreaderCatalogManifestQueryDto,
|
||||||
KoreaderCatalogSectionQueryDto,
|
KoreaderCatalogSectionQueryDto,
|
||||||
KoreaderCatalogSetRatingDto,
|
KoreaderCatalogSetRatingDto,
|
||||||
@@ -29,8 +33,9 @@ export class KoreaderCatalogController {
|
|||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@Header('Cache-Control', 'private, max-age=30')
|
@Header('Cache-Control', 'private, max-age=30')
|
||||||
dashboard(@CurrentUser() user: RequestUser) {
|
dashboard(@CurrentUser() user: RequestUser, @Query() query: KoreaderCatalogDashboardQueryDto) {
|
||||||
return this.catalogService.getDashboard(user);
|
const section = query.section ? { type: query.section, ...(query.smartScopeId ? { smartScopeId: query.smartScopeId } : {}) } : undefined;
|
||||||
|
return this.catalogService.getDashboard(user, section);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard/discover')
|
@Get('dashboard/discover')
|
||||||
@@ -39,6 +44,16 @@ export class KoreaderCatalogController {
|
|||||||
return this.catalogService.getDiscover(user);
|
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')
|
@Get('sections/:section')
|
||||||
@Header('Cache-Control', 'private, max-age=30')
|
@Header('Cache-Control', 'private, max-age=30')
|
||||||
sections(@CurrentUser() user: RequestUser, @Param('section') section: string, @Query() query: KoreaderCatalogSectionQueryDto) {
|
sections(@CurrentUser() user: RequestUser, @Param('section') section: string, @Query() query: KoreaderCatalogSectionQueryDto) {
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ function makeService(
|
|||||||
findByBookIds: vi.fn().mockResolvedValue(new Map([[10, { status: 'reading' }]])),
|
findByBookIds: vi.fn().mockResolvedValue(new Map([[10, { status: 'reading' }]])),
|
||||||
setManual: vi.fn().mockResolvedValue(undefined),
|
setManual: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
const dashboardService = {
|
||||||
|
getScrollerBookIds: vi.fn().mockResolvedValue([11, 10]),
|
||||||
|
getSmartScopeBookIds: vi.fn().mockResolvedValue([10]),
|
||||||
|
};
|
||||||
const dashboardWidgetService = {
|
const dashboardWidgetService = {
|
||||||
getReadingGoal: vi.fn().mockResolvedValue({ goalBooks: 24, completedBooks: 6, year: 2026 }),
|
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] }),
|
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,
|
bookService as never,
|
||||||
bookReadService as never,
|
bookReadService as never,
|
||||||
userBookStatusService as never,
|
userBookStatusService as never,
|
||||||
|
dashboardService as never,
|
||||||
dashboardWidgetService as never,
|
dashboardWidgetService as never,
|
||||||
recommendationService as never,
|
recommendationService as never,
|
||||||
{ isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never,
|
{ isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never,
|
||||||
@@ -233,6 +238,7 @@ function makeService(
|
|||||||
bookService,
|
bookService,
|
||||||
bookReadService,
|
bookReadService,
|
||||||
userBookStatusService,
|
userBookStatusService,
|
||||||
|
dashboardService,
|
||||||
dashboardWidgetService,
|
dashboardWidgetService,
|
||||||
recommendationService,
|
recommendationService,
|
||||||
pluginService,
|
pluginService,
|
||||||
@@ -340,6 +346,96 @@ describe('KoreaderCatalogService', () => {
|
|||||||
expect(dashboardWidgetService.getHighlightOfTheDay).toHaveBeenCalledWith(user);
|
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 () => {
|
it('rerolls discover books via getDiscover', async () => {
|
||||||
const { service, opdsBookService } = makeService();
|
const { service, opdsBookService } = makeService();
|
||||||
const user = makeUser({ id: 7 });
|
const user = makeUser({ id: 7 });
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import type {
|
|||||||
KoreaderCatalogBookDetail,
|
KoreaderCatalogBookDetail,
|
||||||
KoreaderCatalogBookListItem,
|
KoreaderCatalogBookListItem,
|
||||||
KoreaderCatalogDashboardResponse,
|
KoreaderCatalogDashboardResponse,
|
||||||
|
KoreaderCatalogDashboardSection,
|
||||||
|
KoreaderCatalogDashboardSectionResponse,
|
||||||
KoreaderCatalogDiscoverResponse,
|
KoreaderCatalogDiscoverResponse,
|
||||||
|
KoreaderDashboardSectionConfig,
|
||||||
KoreaderCatalogEntry,
|
KoreaderCatalogEntry,
|
||||||
KoreaderCatalogFile,
|
KoreaderCatalogFile,
|
||||||
KoreaderCatalogManifestBook,
|
KoreaderCatalogManifestBook,
|
||||||
@@ -40,6 +43,7 @@ import { storageConfig } from '../../config/config';
|
|||||||
import { BookReadService } from '../book/book-read.service';
|
import { BookReadService } from '../book/book-read.service';
|
||||||
import { BookService } from '../book/book.service';
|
import { BookService } from '../book/book.service';
|
||||||
import type { BookDetailDto } from '../book/dto/book-detail.dto';
|
import type { BookDetailDto } from '../book/dto/book-detail.dto';
|
||||||
|
import { DashboardService } from '../dashboard/dashboard.service';
|
||||||
import { DashboardWidgetService } from '../dashboard/dashboard-widget.service';
|
import { DashboardWidgetService } from '../dashboard/dashboard-widget.service';
|
||||||
import { fileMimeType } from '../opds/opds-xml.helpers';
|
import { fileMimeType } from '../opds/opds-xml.helpers';
|
||||||
import { OpdsBookEntry, OpdsBookService } from '../opds/opds-book.service';
|
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 AUTHOR_SERIES_PAGE_SIZE = 60;
|
||||||
const DASHBOARD_CONTINUE_READING_LIMIT = 5;
|
const DASHBOARD_CONTINUE_READING_LIMIT = 5;
|
||||||
const DASHBOARD_DISCOVER_LIMIT = 10;
|
const DASHBOARD_DISCOVER_LIMIT = 10;
|
||||||
|
const DASHBOARD_SECTION_LIMIT = 10;
|
||||||
const DETAIL_RELATED_LIMIT = 8;
|
const DETAIL_RELATED_LIMIT = 8;
|
||||||
const MANIFEST_DEFAULT_PAGE_SIZE = 100;
|
const MANIFEST_DEFAULT_PAGE_SIZE = 100;
|
||||||
const MANIFEST_CURSOR_VERSION = 1;
|
const MANIFEST_CURSOR_VERSION = 1;
|
||||||
@@ -133,6 +138,7 @@ export class KoreaderCatalogService {
|
|||||||
private readonly bookService: BookService,
|
private readonly bookService: BookService,
|
||||||
private readonly bookReadService: BookReadService,
|
private readonly bookReadService: BookReadService,
|
||||||
private readonly userBookStatusService: UserBookStatusService,
|
private readonly userBookStatusService: UserBookStatusService,
|
||||||
|
private readonly dashboardService: DashboardService,
|
||||||
private readonly dashboardWidgetService: DashboardWidgetService,
|
private readonly dashboardWidgetService: DashboardWidgetService,
|
||||||
private readonly recommendationService: RecommendationService,
|
private readonly recommendationService: RecommendationService,
|
||||||
private readonly appSettingsService: AppSettingsService,
|
private readonly appSettingsService: AppSettingsService,
|
||||||
@@ -145,7 +151,7 @@ export class KoreaderCatalogService {
|
|||||||
return { sections: ROOT_SECTIONS.map((section) => ({ ...section })) };
|
return { sections: ROOT_SECTIONS.map((section) => ({ ...section })) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDashboard(user: RequestUser): Promise<KoreaderCatalogDashboardResponse> {
|
async getDashboard(user: RequestUser, section?: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardResponse> {
|
||||||
const continueReadingQuery = Object.assign(new KoreaderCatalogBooksQueryDto(), {
|
const continueReadingQuery = Object.assign(new KoreaderCatalogBooksQueryDto(), {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: DASHBOARD_CONTINUE_READING_LIMIT,
|
size: DASHBOARD_CONTINUE_READING_LIMIT,
|
||||||
@@ -153,9 +159,10 @@ export class KoreaderCatalogService {
|
|||||||
readStatus: 'reading' as const,
|
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.getBooksPage(user, continueReadingQuery),
|
||||||
this.buildDiscover(user),
|
section ? Promise.resolve<KoreaderCatalogBookListItem[]>([]) : this.buildDiscover(user),
|
||||||
|
section ? this.buildDashboardSection(user, section) : Promise.resolve(undefined),
|
||||||
this.dashboardWidgetService.getReadingGoal(user),
|
this.dashboardWidgetService.getReadingGoal(user),
|
||||||
this.dashboardWidgetService.getReadingStreak(user),
|
this.dashboardWidgetService.getReadingStreak(user),
|
||||||
this.dashboardWidgetService.getHighlightOfTheDay(user),
|
this.dashboardWidgetService.getHighlightOfTheDay(user),
|
||||||
@@ -167,9 +174,10 @@ export class KoreaderCatalogService {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.name || user.username,
|
displayName: user.name || user.username,
|
||||||
totalBooks,
|
totalBooks,
|
||||||
sections: ROOT_SECTIONS.map((section) => ({ ...section })),
|
sections: ROOT_SECTIONS.map((rootSection) => ({ ...rootSection })),
|
||||||
continueReading: continueReading.items,
|
continueReading: continueReading.items,
|
||||||
discover,
|
discover,
|
||||||
|
...(dashboardSection ? { section: dashboardSection } : {}),
|
||||||
readingGoal,
|
readingGoal,
|
||||||
readingStreak,
|
readingStreak,
|
||||||
highlightOfTheDay,
|
highlightOfTheDay,
|
||||||
@@ -180,6 +188,41 @@ export class KoreaderCatalogService {
|
|||||||
return { discover: await this.buildDiscover(user) };
|
return { discover: await this.buildDiscover(user) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardSectionResponse> {
|
||||||
|
return { section: await this.buildDashboardSection(user, section) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildDashboardSection(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogDashboardSection> {
|
||||||
|
return {
|
||||||
|
type: section.type,
|
||||||
|
smartScopeId: section.smartScopeId ?? null,
|
||||||
|
books: await this.buildSectionBooks(user, section),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildSectionBooks(user: RequestUser, section: KoreaderDashboardSectionConfig): Promise<KoreaderCatalogBookListItem[]> {
|
||||||
|
// 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<KoreaderCatalogBookListItem[]> {
|
||||||
|
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<KoreaderCatalogBookListItem[]> {
|
private async buildDiscover(user: RequestUser): Promise<KoreaderCatalogBookListItem[]> {
|
||||||
const entries = await this.opdsBookService.getRandomBooks(user.id, DASHBOARD_DISCOVER_LIMIT, user.isSuperuser, user.contentFilters);
|
const entries = await this.opdsBookService.getRandomBooks(user.id, DASHBOARD_DISCOVER_LIMIT, user.isSuperuser, user.contentFilters);
|
||||||
if (entries.length === 0) return [];
|
if (entries.length === 0) return [];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const PROVISION_FILE = 'bookorbit_provision.lua';
|
|||||||
// Wire features this server advertises. The plugin selects a new route only
|
// Wire features this server advertises. The plugin selects a new route only
|
||||||
// when its name appears here, so a downgraded server transparently returns the
|
// when its name appears here, so a downgraded server transparently returns the
|
||||||
// plugin to its legacy path.
|
// plugin to its legacy path.
|
||||||
const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest'];
|
const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = ['catalogBulkManifest', 'catalogDashboardSections'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KoreaderPackageService {
|
export class KoreaderPackageService {
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ describe('KOReader plugin update source wiring', () => {
|
|||||||
expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")');
|
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');
|
const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua');
|
||||||
|
|
||||||
expect(dashboard).toContain('local DASHBOARD_TALL_ASPECT_RATIO = 1.55');
|
expect(dashboard).toContain('local DASHBOARD_TALL_ASPECT_RATIO = 1.55');
|
||||||
expect(dashboard).toContain('local DISCOVER_COMPACT_GAP = 6');
|
expect(dashboard).toContain('local SECTION_COMPACT_GAP = 6');
|
||||||
expect(dashboard).toContain('local DISCOVER_MAX_ROWS = 2');
|
expect(dashboard).toContain('local SECTION_MAX_ROWS = 2');
|
||||||
expect(dashboard).toContain('local STATS_MIN_BODY_HEIGHT = 56');
|
expect(dashboard).toContain('local STATS_MIN_BODY_HEIGHT = 56');
|
||||||
expect(dashboard).toContain('function CatalogDashboard:dashboardTallLayout()');
|
expect(dashboard).toContain('function CatalogDashboard:dashboardTallLayout()');
|
||||||
expect(dashboard).toContain('function CatalogDashboard:addDashboardCoverGrid(');
|
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('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).toContain('if slot > 1 then');
|
||||||
expect(dashboard).not.toContain('(slots + 1) * gap');
|
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('if fixedHeight() > avail then');
|
||||||
expect(dashboard).toContain('discover_rows * discover_row_h + math.max(0, discover_rows - 1) * inner_gap');
|
expect(dashboard).toContain('section_rows * section_row_h + math.max(0, section_rows - 1) * inner_gap');
|
||||||
expect(dashboard).toContain('show_discover and self:dashboardTallLayout() and discover_slots > 0');
|
expect(dashboard).toContain('show_section and not section_pending and self:dashboardTallLayout() and section_slots > 0');
|
||||||
expect(dashboard).toContain('local discover_page_size = math.max(1, discover_slots * discover_rows)');
|
expect(dashboard).toContain('local section_page_size = math.max(1, section_slots * section_rows)');
|
||||||
expect(dashboard).not.toContain('catalog_dashboard_max_height');
|
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 () => {
|
it('renders the canonical account reading streak on the device dashboard', async () => {
|
||||||
const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua');
|
const dashboard = await readPluginFile('bookorbit_catalog_dashboard.lua');
|
||||||
const catalogUtil = await readPluginFile('bookorbit_catalog_util.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 () => {
|
it('rechecks matched local hashes during full-library revalidation', async () => {
|
||||||
const sweep = await readPluginFile('bookorbit_sweep.lua');
|
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');
|
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('local file_exists = lfs.attributes(file, "mode") == "file"');
|
||||||
expect(sweep).toContain('cand.source = "file"');
|
expect(sweep).toContain('cand.source = "file"');
|
||||||
expect(sweep).toContain('cand.metadata_ambiguous = false');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user