feat(koreader): add not-on-device tile, bulk folder picker, browse counts

Add a "Not on device" Browse tile listing recently added books not yet
downloaded here. On-device state is local to KOReader and has no
server-side filter, so the list is assembled by a bounded walk of the
newest books, capped by both a page budget and the server's ids limit.
It takes the slot of the Search tile, which duplicated the title bar
search button present on every non-detail page.

Add a folder picker to the bulk download settings, sharing one chooser
with the single-book dialog so both write the same download_dir.

Badge every Browse tile the server can count. Authors and series reuse
the cached totals the web sidebar already requests; libraries,
collections, smart scopes and in-progress get dedicated count queries
rather than the list methods behind the section endpoints, which
aggregate per-entity book counts a badge never reads. Badges are
right-aligned and abbreviated past 999 so the column stays fixed.

Replace the shared count path that paged and hydrated a book to read a
single integer, removing five queries per count on the dashboard, the
series read-through summary and the section endpoints.

Closes #869
This commit is contained in:
neon
2026-08-02 22:35:37 +00:00
parent 4be84710bd
commit 57e2616aec
15 changed files with 882 additions and 68 deletions
@@ -58,6 +58,8 @@ local DEFAULT_GRID_COLUMNS = CatalogUtil.DEFAULT_GRID_COLUMNS
local DEFAULT_GRID_ROWS = CatalogUtil.DEFAULT_GRID_ROWS local DEFAULT_GRID_ROWS = CatalogUtil.DEFAULT_GRID_ROWS
local MAX_RECENT_SEARCHES = CatalogUtil.MAX_RECENT_SEARCHES local MAX_RECENT_SEARCHES = CatalogUtil.MAX_RECENT_SEARCHES
local ON_DEVICE_MAX_IDS = CatalogUtil.ON_DEVICE_MAX_IDS local ON_DEVICE_MAX_IDS = CatalogUtil.ON_DEVICE_MAX_IDS
local NOT_ON_DEVICE_SCAN_SIZE = CatalogUtil.NOT_ON_DEVICE_SCAN_SIZE
local scanNotOnDeviceIds = CatalogUtil.scanNotOnDeviceIds
local SORTS = CatalogUtil.SORTS local SORTS = CatalogUtil.SORTS
local SORT_LABELS = CatalogUtil.SORT_LABELS local SORT_LABELS = CatalogUtil.SORT_LABELS
local NATURAL_ORDER = CatalogUtil.NATURAL_ORDER local NATURAL_ORDER = CatalogUtil.NATURAL_ORDER
@@ -236,6 +238,7 @@ function BookOrbitCatalog:init()
self.settings = self.settings or {} self.settings = self.settings or {}
CatalogWidgets.setAssetIconFiles({ CatalogWidgets.setAssetIconFiles({
on_device = self:pluginAssetFile("bookorbit.on-device.svg"), on_device = self:pluginAssetFile("bookorbit.on-device.svg"),
download = self:pluginAssetFile(DOWNLOAD_ICON_FILE),
shuffle = self:pluginAssetFile("bookorbit.shuffle.svg"), shuffle = self:pluginAssetFile("bookorbit.shuffle.svg"),
in_progress = self:pluginAssetFile("bookorbit.in-progress.svg"), in_progress = self:pluginAssetFile("bookorbit.in-progress.svg"),
collections = self:pluginAssetFile("bookorbit.collections.svg"), collections = self:pluginAssetFile("bookorbit.collections.svg"),
@@ -713,9 +716,13 @@ function BookOrbitCatalog:rootItems()
end end
table.insert(items, { table.insert(items, {
text = _("On device"), text = _("On device"),
mandatory = on_device_count > 0 and tostring(on_device_count) or nil, mandatory = on_device_count > 0 and CatalogUtil.formatCount(on_device_count) or nil,
kind = "on-device", kind = "on-device",
}) })
table.insert(items, {
text = _("Not on device"),
kind = "not-on-device",
})
return items return items
end end
@@ -1006,27 +1013,33 @@ function BookOrbitCatalog:paramsForEntry(section, entry)
return params return params
end end
-- Assumes an already-wrapped Trapper coroutine, so a caller that has its own
-- requests to make first can reuse it without nesting another wrap.
function BookOrbitCatalog:fetchBookPage(params, title, push)
local query = cloneParams(params)
query.page = query.page or 1
query.size = self:itemsPerPage()
query.sort = query.sort or self.default_sort or "recently_added"
query.order = self:effectiveOrder(query)
local body, err = self:fetch(_("Loading books..."), function()
return self.client:catalogBooks(query)
end)
if not body then
if err ~= "cancelled" then
self:showRetry(err, function()
self:loadBooks(params, title, push)
end)
end
return
end
self:showBookPage(body, query, title or _("Books"), push ~= false)
end
function BookOrbitCatalog:loadBooks(params, title, push) function BookOrbitCatalog:loadBooks(params, title, push)
self:runConnected(function() self:runConnected(function()
local query = cloneParams(params) self:fetchBookPage(params, title, push)
query.page = query.page or 1
query.size = self:itemsPerPage()
query.sort = query.sort or self.default_sort or "recently_added"
query.order = self:effectiveOrder(query)
local body, err = self:fetch(_("Loading books..."), function()
return self.client:catalogBooks(query)
end)
if not body then
if err ~= "cancelled" then
self:showRetry(err, function()
self:loadBooks(params, title, push)
end)
end
return
end
self:showBookPage(body, query, title or _("Books"), push ~= false)
end) end)
end end
@@ -1048,6 +1061,55 @@ function BookOrbitCatalog:loadOnDevice()
self:loadBooks({ ids = table.concat(capped, ","), sort = "title" }, _("On device"), true) self:loadBooks({ ids = table.concat(capped, ","), sort = "title" }, _("On device"), true)
end end
function BookOrbitCatalog:collectNotOnDeviceIds()
return scanNotOnDeviceIds(function(page)
return self:fetch(_("Finding books not on device..."), function()
return self.client:catalogBooks({
page = page,
size = NOT_ON_DEVICE_SCAN_SIZE,
sort = "recently_added",
order = "desc",
})
end)
end, function(book)
return self:isOnDevice(book)
end)
end
function BookOrbitCatalog:loadNotOnDevice()
self:runConnected(function()
-- Without the maps every book reads as absent, which would list books
-- that are already here. refreshOnDevice starts the scan; the list is
-- worth waiting for rather than showing wrong.
if not self:refreshOnDevice() then
UIManager:show(InfoMessage:new{
text = _("Still checking which books are on this device. Try again in a moment."),
timeout = 3,
})
return
end
local ids, err = self:collectNotOnDeviceIds()
if not ids then
if err ~= "cancelled" then
self:showRetry(err, function()
self:loadNotOnDevice()
end)
end
return
end
if #ids == 0 then
UIManager:show(InfoMessage:new{
text = _("The newest books in your library are already on this device."),
timeout = 3,
})
return
end
-- The tile says "Not on device"; the list says what it actually holds,
-- since the walk covers recent additions rather than the whole library.
self:fetchBookPage({ ids = table.concat(ids, ","), sort = "recently_added" }, _("Not on device (recent)"), true)
end)
end
function BookOrbitCatalog:scopeParams(query) function BookOrbitCatalog:scopeParams(query)
local params = cloneParams(query) local params = cloneParams(query)
params.page = nil params.page = nil
@@ -2224,6 +2286,8 @@ function BookOrbitCatalog:onMenuSelect(item)
end end
elseif item.kind == "on-device" then elseif item.kind == "on-device" then
self:loadOnDevice() self:loadOnDevice()
elseif item.kind == "not-on-device" then
self:loadNotOnDevice()
elseif item.kind == "books" then elseif item.kind == "books" then
self:loadBooks(item.params or {}, item.list_title or item.text) self:loadBooks(item.params or {}, item.list_title or item.text)
elseif item.kind == "book" then elseif item.kind == "book" then
@@ -33,6 +33,7 @@ local cloneParams = CatalogUtil.cloneParams
local formatBytes = CatalogUtil.formatBytes local formatBytes = CatalogUtil.formatBytes
local isSupportedFormat = CatalogUtil.isSupportedFormat local isSupportedFormat = CatalogUtil.isSupportedFormat
local safeFilenameBase = CatalogUtil.safeFilenameBase local safeFilenameBase = CatalogUtil.safeFilenameBase
local shortPath = CatalogUtil.shortPath
local shortText = CatalogUtil.shortText local shortText = CatalogUtil.shortText
local STEP_DELAY = 0.15 local STEP_DELAY = 0.15
@@ -40,6 +41,7 @@ local NEXT_ITEM_DELAY = 0.02
local PROGRESS_THROTTLE = 1 local PROGRESS_THROTTLE = 1
local FAILED_TITLES_LIMIT = 8 local FAILED_TITLES_LIMIT = 8
local TITLE_LINE_LENGTH = 38 local TITLE_LINE_LENGTH = 38
local FOLDER_LABEL_LENGTH = 32
local MANIFEST_FEATURE = "catalogBulkManifest" local MANIFEST_FEATURE = "catalogBulkManifest"
local PAGE_SIZE = 100 local PAGE_SIZE = 100
@@ -411,6 +413,17 @@ function CatalogBulkDownload.install(Catalog)
dialog = ButtonDialog:new{ dialog = ButtonDialog:new{
title = _("Bulk download settings"), title = _("Bulk download settings"),
buttons = { buttons = {
{
{
text = T(_("Folder: %1"), BD.dirpath(shortPath(self:getCurrentDownloadDir(), FOLDER_LABEL_LENGTH))),
callback = function()
UIManager:close(dialog)
self:chooseDownloadDir(function()
self:showBulkDownloadSettings()
end)
end,
},
},
{ {
{ {
text = T(_("Format: %1"), preset.label), text = T(_("Format: %1"), preset.label),
@@ -350,43 +350,60 @@ function CatalogDashboard.dashboardBooks(dashboard)
return books return books
end end
function CatalogDashboard:dashboardActionEntries(total_books) -- Badges come from the server alongside the rest of the dashboard, so a server
-- too old to send them just leaves the tiles unbadged. Not on device is the one
-- tile that never carries a count: learning it would cost the whole-library walk
-- the tile exists to avoid.
function CatalogDashboard:dashboardActionEntries(total_books, browse_counts)
local counts = type(browse_counts) == "table" and browse_counts or {}
local badge = CatalogUtil.formatCount
return { return {
{ {
text = _("In progress"), text = _("In progress"),
icon = "dogear.reading", icon = "dogear.reading",
icon_file = CatalogWidgets.assetIconFile("in_progress"), icon_file = CatalogWidgets.assetIconFile("in_progress"),
mandatory = badge(counts.inProgress),
kind = "books", kind = "books",
params = { sort = "recently_read", readStatus = "reading" }, params = { sort = "recently_read", readStatus = "reading" },
}, },
{ {
text = _("On device"), text = _("On device"),
icon = "appbar.filebrowser", icon = "appbar.filebrowser",
mandatory = tostring(self:onDeviceCount()), mandatory = badge(self:onDeviceCount()),
kind = "on-device", kind = "on-device",
}, },
{
text = _("Not on device"),
icon = "appbar.filebrowser",
icon_file = CatalogWidgets.assetIconFile("download"),
kind = "not-on-device",
},
{ {
text = _("Libraries"), text = _("Libraries"),
icon = "column.two", icon = "column.two",
mandatory = badge(counts.libraries),
kind = "section", kind = "section",
section = "libraries", section = "libraries",
}, },
{ {
text = _("All Books"), text = _("All Books"),
icon = "appbar.pageview", icon = "appbar.pageview",
mandatory = total_books and tostring(total_books) or nil, mandatory = badge(total_books),
kind = "books", kind = "books",
params = { sort = "title" }, params = { sort = "title" },
}, },
{ {
text = _("Authors"), text = _("Authors"),
icon = "bookmark", icon = "bookmark",
mandatory = badge(counts.authors),
kind = "section", kind = "section",
section = "authors", section = "authors",
}, },
{ {
text = _("Series"), text = _("Series"),
icon = "book.opened", icon = "book.opened",
mandatory = badge(counts.series),
kind = "section", kind = "section",
section = "series", section = "series",
}, },
@@ -394,20 +411,17 @@ function CatalogDashboard:dashboardActionEntries(total_books)
text = _("Collections"), text = _("Collections"),
icon = "texture-box", icon = "texture-box",
icon_file = CatalogWidgets.assetIconFile("collections"), icon_file = CatalogWidgets.assetIconFile("collections"),
mandatory = badge(counts.collections),
kind = "section", kind = "section",
section = "collections", section = "collections",
}, },
{ {
text = _("SmartScopes"), text = _("SmartScopes"),
icon = "cre.render.working", icon = "cre.render.working",
mandatory = badge(counts.smartScopes),
kind = "section", kind = "section",
section = "smart-scopes", section = "smart-scopes",
}, },
{
text = _("Search"),
icon = "appbar.search",
kind = "dashboard-search",
},
} }
end end
@@ -933,7 +947,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local context = self.current_context or {} local context = self.current_context or {}
local dashboard = context.dashboard or {} local dashboard = context.dashboard or {}
local continue_books = dashboard.continueReading or {} local continue_books = dashboard.continueReading or {}
local action_entries = self:dashboardActionEntries(tonumber(dashboard.totalBooks or dashboard.bookCount)) local action_entries = self:dashboardActionEntries(tonumber(dashboard.totalBooks or dashboard.bookCount), dashboard.browseCounts)
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 }
local stale_sections = type(context.section_stale) == "table" and context.section_stale or {} local stale_sections = type(context.section_stale) == "table" and context.section_stale or {}
local highlight = type(dashboard.highlightOfTheDay) == "table" and dashboard.highlightOfTheDay or nil local highlight = type(dashboard.highlightOfTheDay) == "table" and dashboard.highlightOfTheDay or nil
@@ -113,6 +113,27 @@ function CatalogDownload.install(Catalog)
return G_reader_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir") return G_reader_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir")
end end
-- Single and bulk downloads share one destination, KOReader's own
-- download_dir, so the chooser is shared too rather than each dialog
-- keeping its own idea of where files land.
function Catalog:chooseDownloadDir(on_chosen)
require("ui/downloadmgr"):new{
onConfirm = function(path)
logger.dbg("BookOrbit: download folder set to", path)
if self._manager and self._manager.ui and self._manager.ui.folder_shortcuts
and self._manager.ui.folder_shortcuts.updateShortcut then
self._manager.ui.folder_shortcuts:updateShortcut("download_dir", path)
end
G_reader_settings:saveSetting("download_dir", path)
if on_chosen then
UIManager:nextTick(function()
on_chosen(path)
end)
end
end,
}:chooseDir(self:getCurrentDownloadDir())
end
function Catalog:getLocalDownloadPath(filename, filetype, device_path, filename_override) function Catalog:getLocalDownloadPath(filename, filetype, device_path, filename_override)
local download_dir = self:getCurrentDownloadDir() local download_dir = self:getCurrentDownloadDir()
local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override) local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
@@ -200,19 +221,9 @@ function CatalogDownload.install(Catalog)
text = _("Choose folder"), text = _("Choose folder"),
callback = function() callback = function()
UIManager:close(dialog) UIManager:close(dialog)
require("ui/downloadmgr"):new{ self:chooseDownloadDir(function()
onConfirm = function(path) self:showDownloadDialog(detail, file, filename_overridden and filename or nil)
logger.dbg("BookOrbit: download folder set to", path) end)
if self._manager and self._manager.ui and self._manager.ui.folder_shortcuts
and self._manager.ui.folder_shortcuts.updateShortcut then
self._manager.ui.folder_shortcuts:updateShortcut("download_dir", path)
end
G_reader_settings:saveSetting("download_dir", path)
UIManager:nextTick(function()
self:showDownloadDialog(detail, file, filename_overridden and filename or nil)
end)
end,
}:chooseDir(self:getCurrentDownloadDir())
end, end,
}, },
{ {
@@ -20,7 +20,15 @@ CatalogUtil.DEFAULT_GRID_ROWS = 3
-- batch and the page rebuilds once when it lands. -- batch and the page rebuilds once when it lands.
CatalogUtil.THUMBNAIL_BATCH_SIZE = 3 CatalogUtil.THUMBNAIL_BATCH_SIZE = 3
CatalogUtil.MAX_RECENT_SEARCHES = 8 CatalogUtil.MAX_RECENT_SEARCHES = 8
-- The server caps its ids filter at 200, so both device-derived lists are
-- bounded by it rather than by the size of the library.
CatalogUtil.ON_DEVICE_MAX_IDS = 200 CatalogUtil.ON_DEVICE_MAX_IDS = 200
CatalogUtil.NOT_ON_DEVICE_MAX_IDS = 200
-- Not-on-device has no server-side filter, so the list is assembled by walking
-- the newest books. Both bounds keep that walk to a fixed number of requests
-- however large the library is.
CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES = 5
CatalogUtil.NOT_ON_DEVICE_SCAN_SIZE = 100
CatalogUtil.SORTS = { CatalogUtil.SORTS = {
{ id = "title", text = _("Title") }, { id = "title", text = _("Title") },
@@ -120,6 +128,32 @@ function CatalogUtil.formatBytes(bytes)
return tostring(bytes) .. " B" return tostring(bytes) .. " B"
end end
-- Browse badges sit in a narrow column beside the label, so a five-digit
-- library would eat the room the label needs. Counts stay at most four
-- characters. The scaled forms truncate rather than round, so a badge never
-- claims more than the library holds and 999999 cannot carry to "1000K".
function CatalogUtil.formatCount(value)
local count = tonumber(value)
if not count or count < 0 then return nil end
count = math.floor(count)
if count < 1000 then return tostring(count) end
local function scaled(divisor, suffix)
local units = count / divisor
if units >= 10 then
return string.format("%d%s", math.floor(units), suffix)
end
local tenths = math.floor(units * 10) / 10
if tenths == math.floor(tenths) then
return string.format("%d%s", tenths, suffix)
end
return string.format("%.1f%s", tenths, suffix)
end
if count < 1000000 then return scaled(1000, "K") end
return scaled(1000000, "M")
end
function CatalogUtil.formatDuration(seconds) function CatalogUtil.formatDuration(seconds)
if not seconds then return nil end if not seconds then return nil end
local minutes = math.floor(seconds / 60 + 0.5) local minutes = math.floor(seconds / 60 + 0.5)
@@ -213,6 +247,35 @@ function CatalogUtil.shortText(text, max_len)
return util.fixUtf8(text:sub(1, max_len - 3), "?") .. "..." return util.fixUtf8(text:sub(1, max_len - 3), "?") .. "..."
end end
-- Walks the newest books through fetchPage(page) and keeps the ids isOnDevice
-- rejects. There is no server-side filter for this: which books are linked is
-- local KOReader state the server never sees, so the walk is bounded by both
-- the page count and the server's cap on the ids filter to stay predictable on
-- a large library. Returns nil plus the fetch error if a page fails.
function CatalogUtil.scanNotOnDeviceIds(fetchPage, isOnDevice)
local ids = {}
for page = 1, CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES do
local body, err = fetchPage(page)
if not body then return nil, err end
for _, book in ipairs(body.items or {}) do
if book.id ~= nil and not isOnDevice(book) then
table.insert(ids, book.id)
if #ids >= CatalogUtil.NOT_ON_DEVICE_MAX_IDS then return ids end
end
end
if body.hasNext ~= true then break end
end
return ids
end
-- Paths carry their meaning at the end, so an over-long one keeps its tail
-- rather than its root the way shortText would.
function CatalogUtil.shortPath(path, max_len)
path = tostring(path or "")
if #path <= max_len then return path end
return "..." .. util.fixUtf8(path:sub(#path - max_len + 4), "?")
end
function CatalogUtil.joinNames(items, key) function CatalogUtil.joinNames(items, key)
local names = {} local names = {}
for _, item in ipairs(items or {}) do for _, item in ipairs(items or {}) do
@@ -1414,9 +1414,11 @@ function DashboardBrowseRow:init()
fgcolor = Blitbuffer.COLOR_DARK_GRAY, fgcolor = Blitbuffer.COLOR_DARK_GRAY,
} }
end end
-- Wide counts (a four-digit library) get the width they measure at instead -- Counts are abbreviated to at most four characters upstream, so the column
-- of being clipped to the nominal box. -- only has to be wide enough for the widest of those rather than for an
local count_w = count_widget and math.max(count_box_w, count_widget:getSize().w + Size.padding.small) or 0 -- arbitrary library total.
local count_pad = Size.padding.small
local count_w = count_widget and math.max(count_box_w, count_widget:getSize().w + count_pad) or 0
local label_w = math.max(1, self.dimen.w - icon_box_w - gap - count_w) local label_w = math.max(1, self.dimen.w - icon_box_w - gap - count_w)
local left = HorizontalGroup:new{ align = "center" } local left = HorizontalGroup:new{ align = "center" }
@@ -1443,10 +1445,13 @@ function DashboardBrowseRow:init()
dimen = row_dimen:copy(), dimen = row_dimen:copy(),
LeftContainer:new{ dimen = row_dimen:copy(), left }, LeftContainer:new{ dimen = row_dimen:copy(), left },
} }
-- Right-aligned rather than centred in the count box: centring leaves each
-- badge a different distance from the edge, so a column of counts reads as
-- ragged even though every box ends in the same place.
if count_widget then if count_widget then
table.insert(row, RightContainer:new{ table.insert(row, RightContainer:new{
dimen = row_dimen:copy(), dimen = Geom:new{ w = math.max(1, row_dimen.w - count_pad), h = inner_h },
CenterContainer:new{ dimen = Geom:new{ w = count_w, h = inner_h }, count_widget }, count_widget,
}) })
end end
@@ -0,0 +1,218 @@
-- The Browse block is a fixed three-by-three grid, so its entry list has to
-- stay exactly nine long: a tenth destination would be built and then fall off
-- the bottom of the block unseen. Search is not among them because the title
-- bar already carries a search button on every non-detail page, the same reason
-- the non-dashboard root list has never offered it as a row.
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
package.loaded["util"] = {
fixUtf8 = function(value) return value end,
urlEncode = function(value) return value end,
}
package.loaded["ffi/util"] = { template = function(value) return value end }
package.loaded["gettext"] = function(text) return text end
local function widgetClass(height)
return {
new = function(_, opts)
opts = opts or {}
if not opts.getSize then
function opts:getSize()
return { w = 100, h = self.width or height or 20 }
end
end
return opts
end,
}
end
package.loaded["ffi/blitbuffer"] = { COLOR_DARK_GRAY = 1, COLOR_LIGHT_GRAY = 2 }
package.loaded["ui/widget/button"] = widgetClass()
package.loaded["ui/widget/container/centercontainer"] = widgetClass()
package.loaded["ui/font"] = { getFace = function() return {} end }
package.loaded["ui/geometry"] = widgetClass()
package.loaded["ui/widget/horizontalgroup"] = widgetClass(0)
package.loaded["ui/widget/horizontalspan"] = widgetClass(0)
package.loaded["ui/widget/infomessage"] = widgetClass()
package.loaded["ui/widget/linewidget"] = widgetClass()
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
package.loaded["ui/size"] = {
line = { thin = 1 },
padding = { large = 8 },
span = { horizontal_default = 4, vertical_default = 4 },
}
package.loaded["ui/widget/textboxwidget"] = widgetClass()
package.loaded["ui/widget/textwidget"] = widgetClass()
package.loaded["ui/uimanager"] = { show = function() end }
package.loaded["ui/widget/verticalgroup"] = widgetClass(0)
package.loaded["ui/widget/verticalspan"] = widgetClass()
package.loaded["bookorbit_stats_reader"] = {
getReadingSummary = function()
return { today_seconds = 0, week_seconds = 0, streak_days = 0 }
end,
}
package.loaded["bookorbit_catalog_widgets"] = {
DashboardCoverCard = widgetClass(),
DashboardHeroCard = widgetClass(),
DashboardHighlightCard = widgetClass(),
DashboardBrowseRow = widgetClass(),
DashboardIconButton = widgetClass(),
assetIconFile = function(name) return "/assets/" .. name .. ".svg" end,
dashboardSectionHeaderRowHeight = function() return 14 end,
buildDashboardSectionHeader = function()
return { getSize = function() return { w = 100, h = 20 } end }
end,
buildStatusLabel = function()
return { getSize = function() return { w = 100, h = 16 } end }
end,
dashboardHighlightCardHeight = function() return 64 end,
coverCardHeight = function(card_w) return card_w + 20 end,
dashboardHeroHeightForCoverCard = function(card_w) return card_w + 24 end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local CatalogDashboard = require("bookorbit_catalog_dashboard")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local catalog = {}
for name, fn in pairs(CatalogDashboard) do
if name ~= "install" then catalog[name] = fn end
end
function catalog:onDeviceCount() return 7 end
local BROWSE_COUNTS = {
inProgress = 4,
libraries = 3,
authors = 812,
series = 96,
collections = 5,
smartScopes = 2,
}
local entries = catalog:dashboardActionEntries(120, BROWSE_COUNTS)
-- Three columns by three rows. The block renders what fits and no more.
assertEqual(#entries, 9, "the Browse block holds exactly nine destinations")
local by_text = {}
local by_kind = {}
for index, entry in ipairs(entries) do
assertEqual(type(entry.text), "string", "entry " .. index .. " is labelled")
assertEqual(entry.text ~= "", true, "entry " .. index .. " has a non-empty label")
assertEqual(entry.icon ~= nil, true, "entry " .. index .. " has a fallback glyph")
assertEqual(by_text[entry.text], nil, "entry " .. index .. " is not a duplicate label")
by_text[entry.text] = entry
by_kind[entry.kind] = (by_kind[entry.kind] or 0) + 1
end
-- The title bar's search button reaches the same prompt with the same empty
-- scope, so a row for it only spent a grid cell.
assertEqual(by_text["Search"], nil, "Search is not duplicated into the grid")
assertEqual(by_kind["dashboard-search"], nil, "no entry routes to the search prompt")
-- The pair the download workflow is built around: what is here, and what is not.
assertEqual(by_text["On device"] ~= nil, true, "On device is offered")
assertEqual(by_text["On device"].kind, "on-device", "On device routes to the linked list")
assertEqual(by_text["On device"].mandatory, "7", "On device carries its count")
assertEqual(by_text["Not on device"] ~= nil, true, "Not on device is offered")
assertEqual(by_text["Not on device"].kind, "not-on-device", "Not on device routes to the walk")
-- The count would cost a full library walk to learn, so the row stays quiet
-- rather than showing a number that is only a guess.
assertEqual(by_text["Not on device"].mandatory, nil, "Not on device shows no count")
assertEqual(
by_text["Not on device"].icon_file ~= by_text["On device"].icon_file,
true,
"the two device rows are told apart by their icons"
)
-- The catalogs reached through the grid still are.
for _, label in ipairs({ "In progress", "Libraries", "All Books", "Authors", "Series", "Collections", "SmartScopes" }) do
assertEqual(by_text[label] ~= nil, true, label .. " survived the swap")
end
assertEqual(by_text["All Books"].mandatory, "120", "All Books carries the library total")
-- Every tile the server can count carries its badge. Eight of the nine: the
-- device pair is local, All Books rides the dashboard total, and the rest come
-- from the browse counts.
assertEqual(by_text["In progress"].mandatory, "4", "In progress badge")
assertEqual(by_text["Libraries"].mandatory, "3", "Libraries badge")
assertEqual(by_text["Authors"].mandatory, "812", "Authors badge")
assertEqual(by_text["Series"].mandatory, "96", "Series badge")
assertEqual(by_text["Collections"].mandatory, "5", "Collections badge")
assertEqual(by_text["SmartScopes"].mandatory, "2", "SmartScopes badge")
local badged = 0
for _, entry in ipairs(entries) do
if entry.mandatory ~= nil then badged = badged + 1 end
end
assertEqual(badged, 8, "eight of the nine tiles can be counted")
-- Zero is a real answer and has to render, not be swallowed as falsy the way a
-- plain truthiness check on the count would.
local empty = catalog:dashboardActionEntries(0, {
inProgress = 0, libraries = 0, authors = 0, series = 0, collections = 0, smartScopes = 0,
})
for _, entry in ipairs(empty) do
if entry.kind ~= "not-on-device" then
assertEqual(entry.mandatory ~= nil, true, entry.text .. " renders a zero count")
end
end
-- A server too old to send the counts, and a server that sends nonsense, both
-- degrade to unbadged tiles rather than rendering "nil" into the grid.
for _, payload in ipairs({ { nil }, { "not a table" }, { { authors = "many", series = {} } } }) do
local degraded = catalog:dashboardActionEntries(nil, payload[1])
assertEqual(#degraded, 9, "the grid is still full")
for _, entry in ipairs(degraded) do
if entry.kind ~= "on-device" then
assertEqual(entry.mandatory, nil, entry.text .. " has no badge without usable counts")
end
end
end
-- Badges share a narrow column with the label, so the string is bounded at four
-- characters however large the library gets.
local CatalogUtil = require("bookorbit_catalog_util")
local formatCount = CatalogUtil.formatCount
assertEqual(formatCount(0), "0", "zero is a count, not an absence")
assertEqual(formatCount(7), "7", "single digit")
assertEqual(formatCount(999), "999", "the last unscaled value")
assertEqual(formatCount(1000), "1K", "a whole thousand drops its decimal")
assertEqual(formatCount(1500), "1.5K", "a half thousand keeps one decimal")
assertEqual(formatCount(9999), "9.9K", "just under ten thousand")
assertEqual(formatCount(10000), "10K", "ten thousand and up lose the decimal")
assertEqual(formatCount(12431), "12K", "a large library")
assertEqual(formatCount(999999), "999K", "the last value before millions")
assertEqual(formatCount(1000000), "1M", "millions scale again")
assertEqual(formatCount(1500000), "1.5M", "millions keep one decimal")
-- Truncating, not rounding: a badge must never claim more books than are there,
-- and rounding 999999 up would carry it to a five-character "1000K".
assertEqual(formatCount(1999), "1.9K", "the scaled value truncates")
assertEqual(formatCount(1099), "1K", "truncation can drop the decimal entirely")
for _, value in ipairs({ 0, 7, 999, 1000, 1500, 9999, 10000, 999999, 1000000, 1500000 }) do
assertEqual(#formatCount(value) <= 4, true, "the badge for " .. value .. " fits the column")
end
-- Anything that is not a usable count leaves the tile unbadged rather than
-- rendering a placeholder into the grid.
for _, value in ipairs({ "many", -1 }) do
assertEqual(formatCount(value), nil, "unusable count " .. tostring(value) .. " has no badge")
end
assertEqual(formatCount(nil), nil, "a missing count has no badge")
assertEqual(formatCount({}), nil, "a malformed count has no badge")
-- A count arriving as a numeric string still counts, since the payload is JSON.
assertEqual(formatCount("1500"), "1.5K", "a numeric string is accepted")
print("bookorbit_dashboard_browse_entries_test.lua: ok")
@@ -0,0 +1,163 @@
-- Single and bulk downloads write to one destination, KOReader's own
-- download_dir, so both dialogs have to offer the same chooser and see each
-- other's choice. The bulk popup previously only reported the folder in its
-- confirmation text, which left the setting reachable from the single-book
-- dialog alone.
local shown = {}
local next_ticks = {}
package.loaded["ui/bidi"] = {
dirpath = function(value) return value end,
filepath = function(value) return value end,
}
package.loaded["datastorage"] = {
getSettingsDir = function() return "/tmp/bookorbit-spec" end,
getDataDir = function() return "/tmp/bookorbit-spec" end,
}
package.loaded["dump"] = function(value) return tostring(value) end
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
package.loaded["ui/widget/buttondialog"] = {
new = function(_, opts)
opts = opts or {}
function opts:setTitle(title) self.title = title end
return opts
end,
}
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts or {} end }
package.loaded["ui/widget/confirmbox"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/widget/inputdialog"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/widget/notification"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/uimanager"] = {
show = function(_, widget) table.insert(shown, widget) end,
close = function() end,
nextTick = function(_, callback) table.insert(next_ticks, callback) end,
scheduleIn = function(_, _, callback) table.insert(next_ticks, callback) end,
preventStandby = function() end,
allowStandby = function() end,
forceRePaint = function() end,
}
package.loaded["libs/libkoreader-lfs"] = {
attributes = function() return nil end,
dir = function() return function() return nil end end,
}
package.loaded["logger"] = { dbg = function() end, warn = function() end }
package.loaded["util"] = {
fixUtf8 = function(value) return value end,
getSafeFilename = function(value) return value end,
makePath = function() end,
trim = function(value) return value end,
}
package.loaded["ffi/util"] = {
template = function(value, ...)
local values = { ... }
return (tostring(value):gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
package.loaded["gettext"] = function(text) return text end
package.loaded["bookorbit_state_manager"] = {}
package.loaded["bookorbit_download_transfer"] = {}
package.loaded["bookorbit_bulk_checkpoint"] = { MAX_FAILURES = 8 }
package.loaded["bookorbit_capabilities"] = { has = function() return false end }
-- The chooser is KOReader's own directory browser; the test drives its confirm
-- callback directly rather than rendering it.
local chooser = { opened = 0, start_dir = nil, confirm = nil }
package.loaded["ui/downloadmgr"] = {
new = function(_, opts)
return {
chooseDir = function(_, start_dir)
chooser.opened = chooser.opened + 1
chooser.start_dir = start_dir
chooser.confirm = opts.onConfirm
end,
}
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BulkDownload = require("bookorbit_catalog_bulk_download")
local CatalogDownload = require("bookorbit_catalog_download")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local stored = { download_dir = "/mnt/onboard/Books" }
local saved = {}
G_reader_settings = {
readSetting = function(_, key) return stored[key] end,
saveSetting = function(_, key, value)
stored[key] = value
table.insert(saved, { key = key, value = value })
end,
flush = function() end,
}
local Catalog = {}
BulkDownload.install(Catalog)
CatalogDownload.install(Catalog)
Catalog.settings = {}
Catalog:initBulkDownloadState()
local function findButton(dialog, prefix)
for _, row in ipairs(dialog.buttons or {}) do
for _, button in ipairs(row) do
if button.text and button.text:sub(1, #prefix) == prefix then return button end
end
end
return nil
end
-- The bulk settings popup offers the folder alongside the other two knobs.
Catalog:showBulkDownloadSettings()
local settings_dialog = shown[#shown]
assertEqual(settings_dialog.title, "Bulk download settings", "the settings popup opened")
local folder_button = findButton(settings_dialog, "Folder:")
assertEqual(folder_button ~= nil, true, "the folder is offered as a button")
assertEqual(folder_button.text, "Folder: /mnt/onboard/Books", "the button reports the current folder")
assertEqual(findButton(settings_dialog, "Format:") ~= nil, true, "the format knob is still offered")
assertEqual(findButton(settings_dialog, "Existing files:") ~= nil, true, "the existing-files knob is still offered")
-- Choosing a folder writes the one setting the download path is built from.
folder_button.callback()
assertEqual(chooser.opened, 1, "the chooser opened")
assertEqual(chooser.start_dir, "/mnt/onboard/Books", "the chooser starts at the current folder")
chooser.confirm("/mnt/onboard/Books/BookOrbit")
assertEqual(#saved, 1, "one setting was written")
assertEqual(saved[1].key, "download_dir", "the shared download_dir is what changed")
assertEqual(saved[1].value, "/mnt/onboard/Books/BookOrbit", "the chosen path was stored")
-- The popup reopens on the next tick so the new folder is visible without the
-- user having to find their way back into the menu.
assertEqual(#next_ticks, 1, "the caller was scheduled to reopen")
next_ticks[1]()
assertEqual(findButton(shown[#shown], "Folder:").text, "Folder: /mnt/onboard/Books/BookOrbit",
"the reopened popup shows the new folder")
-- The same choice is what a bulk run then downloads into, and what its
-- confirmation reports.
assertEqual(Catalog:getCurrentDownloadDir(), "/mnt/onboard/Books/BookOrbit", "bulk downloads follow the choice")
local title = Catalog:bulkConfirmationTitle({ total = 3, label = "Current page" })
assertEqual(title:find("Folder: /mnt/onboard/Books/BookOrbit", 1, true) ~= nil, true,
"the confirmation reports the chosen folder")
-- An over-long path keeps the end, which is the part that says where the files
-- actually land; truncating the other way would leave every folder looking the
-- same.
local CatalogUtil = require("bookorbit_catalog_util")
local deep = "/mnt/onboard/Books/Fiction/Science Fiction/Series/Long Name"
local short = CatalogUtil.shortPath(deep, 24)
assertEqual(#short, 24, "the label is bounded")
assertEqual(short:sub(1, 3), "...", "the truncation is marked")
assertEqual(deep:sub(-10), short:sub(-10), "the tail of the path survives")
assertEqual(CatalogUtil.shortPath("/short", 24), "/short", "a short path is left alone")
print("bookorbit_download_folder_test.lua: ok")
@@ -0,0 +1,140 @@
-- "Not on device" is the one book source with no server-side filter behind it:
-- which books are linked is local KOReader state the server never sees. The
-- list is therefore walked client-side, and what matters is that the walk stays
-- bounded on a large library, stops as soon as the server runs out of pages,
-- and reports a failed page instead of silently returning a short list.
package.loaded["document/documentregistry"] = {
hasProvider = function()
return true
end,
}
package.loaded["util"] = {
fixUtf8 = function(value)
return value
end,
}
package.loaded["ffi/util"] = {
template = function(value)
return value
end,
}
package.loaded["gettext"] = function(text)
return text
end
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local CatalogUtil = require("bookorbit_catalog_util")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function books(from, to)
local items = {}
for id = from, to do
table.insert(items, { id = id })
end
return items
end
local function pager(pages)
local requested = {}
return function(page)
table.insert(requested, page)
local body = pages[page]
if not body then return nil, 500 end
return body
end, requested
end
local function keepAll() return false end
-- The books already here are dropped, and the rest keep the server's order.
local fetch, requested = pager({
{ items = { { id = 1 }, { id = 2 }, { id = 3 }, { id = 4 } }, hasNext = false },
})
local on_device = { [2] = true, [4] = true }
local ids = CatalogUtil.scanNotOnDeviceIds(fetch, function(book)
return on_device[book.id] == true
end)
assertEqual(#ids, 2, "linked books are dropped")
assertEqual(ids[1], 1, "first kept id")
assertEqual(ids[2], 3, "order follows the server")
assertEqual(#requested, 1, "a single page was enough")
-- A book with no id cannot be asked for again, so it never reaches the list.
local unidentified = CatalogUtil.scanNotOnDeviceIds(function()
return { items = { { id = 1 }, {}, { id = 3 } }, hasNext = false }
end, keepAll)
assertEqual(#unidentified, 2, "an entry without an id is skipped")
-- hasNext false ends the walk even though the page budget is not spent.
local short_fetch, short_requested = pager({
{ items = books(1, 10), hasNext = true },
{ items = books(11, 15), hasNext = false },
{ items = books(16, 20), hasNext = true },
})
local short = CatalogUtil.scanNotOnDeviceIds(short_fetch, keepAll)
assertEqual(#short, 15, "the walk stops when the server says there is no next page")
assertEqual(#short_requested, 2, "no page is requested past the end")
-- A library larger than the walk never turns into an unbounded scan: the page
-- budget caps the requests whatever the server keeps offering.
local endless_pages = {}
for page = 1, CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES + 3 do
endless_pages[page] = { items = books((page - 1) * 10 + 1, page * 10), hasNext = true }
end
local endless_fetch, endless_requested = pager(endless_pages)
local bounded = CatalogUtil.scanNotOnDeviceIds(endless_fetch, keepAll)
assertEqual(#endless_requested, CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES, "the page budget bounds the walk")
assertEqual(#bounded, CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES * 10, "every scanned book is kept")
-- The server caps its ids filter, so the walk stops collecting at that cap
-- rather than building a list the follow-up request would reject.
local cap_pages = {}
for page = 1, CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES do
local from = (page - 1) * CatalogUtil.NOT_ON_DEVICE_SCAN_SIZE + 1
cap_pages[page] = {
items = books(from, from + CatalogUtil.NOT_ON_DEVICE_SCAN_SIZE - 1),
hasNext = true,
}
end
local cap_fetch, cap_requested = pager(cap_pages)
local capped = CatalogUtil.scanNotOnDeviceIds(cap_fetch, keepAll)
assertEqual(#capped, CatalogUtil.NOT_ON_DEVICE_MAX_IDS, "collection stops at the ids cap")
assertEqual(
#cap_requested <= CatalogUtil.NOT_ON_DEVICE_SCAN_PAGES,
true,
"the cap is reached without exceeding the page budget"
)
-- A failed page is an error, not a short list: returning what was gathered so
-- far would read as "these are the books you are missing".
local failed, err = CatalogUtil.scanNotOnDeviceIds(function(page)
if page == 1 then return { items = books(1, 10), hasNext = true } end
return nil, 503
end, keepAll)
assertEqual(failed, nil, "a failed page returns no list")
assertEqual(err, 503, "the fetch error is passed through")
-- Cancelling mid-walk has to stay distinguishable so the caller can stay quiet
-- rather than offering a retry the user did not ask for.
local _, cancelled = CatalogUtil.scanNotOnDeviceIds(function()
return nil, "cancelled"
end, keepAll)
assertEqual(cancelled, "cancelled", "cancellation is reported as itself")
-- Everything already here is the empty case the caller messages separately.
local none = CatalogUtil.scanNotOnDeviceIds(function()
return { items = books(1, 5), hasNext = false }
end, function() return true end)
assertEqual(#none, 0, "a fully downloaded library yields nothing")
print("bookorbit_not_on_device_test.lua: ok")
+14
View File
@@ -300,11 +300,25 @@ export interface KoreaderCatalogDashboardSection {
books: KoreaderCatalogBookListItem[]; books: KoreaderCatalogBookListItem[];
} }
// Totals behind the dashboard's Browse tiles. Optional so a plugin reading an
// older server simply renders the tiles without badges. There is deliberately
// no not-on-device total: which books are on a device is local KOReader state
// the server never sees.
export interface KoreaderCatalogBrowseCounts {
inProgress: number;
libraries: number;
authors: number;
series: number;
collections: number;
smartScopes: number;
}
export interface KoreaderCatalogDashboardResponse { export interface KoreaderCatalogDashboardResponse {
generatedAt: string; generatedAt: string;
username: string; username: string;
displayName: string; displayName: string;
totalBooks: number; totalBooks: number;
browseCounts?: KoreaderCatalogBrowseCounts;
sections: KoreaderCatalogEntry[]; sections: KoreaderCatalogEntry[];
continueReading: KoreaderCatalogBookListItem[]; continueReading: KoreaderCatalogBookListItem[];
// Legacy Discover row. Populated only for requests that name no section, so a // Legacy Discover row. Populated only for requests that name no section, so a
@@ -10,5 +10,6 @@ import { BrowseCountsService } from './browse-counts.service';
imports: [AuthorsModule, SeriesModule, AnnotationModule], imports: [AuthorsModule, SeriesModule, AnnotationModule],
controllers: [BrowseCountsController], controllers: [BrowseCountsController],
providers: [BrowseCountsService], providers: [BrowseCountsService],
exports: [BrowseCountsService],
}) })
export class BrowseCountsModule {} export class BrowseCountsModule {}
@@ -100,6 +100,10 @@ function makeService(
getDistinctAuthorsPage: vi.fn().mockResolvedValue({ items: [{ name: 'Frank Herbert', bookCount: 2 }], hasNext: false }), getDistinctAuthorsPage: vi.fn().mockResolvedValue({ items: [{ name: 'Frank Herbert', bookCount: 2 }], hasNext: false }),
getDistinctSeriesPage: vi.fn().mockResolvedValue({ items: [{ id: 42, name: 'Dune', bookCount: 6 }], hasNext: false }), getDistinctSeriesPage: vi.fn().mockResolvedValue({ items: [{ id: 42, name: 'Dune', bookCount: 6 }], hasNext: false }),
getBooksPage: vi.fn().mockResolvedValue({ entries: [], total: 0 }), getBooksPage: vi.fn().mockResolvedValue({ entries: [], total: 0 }),
countBooks: vi.fn().mockResolvedValue(99),
countUserCollections: vi.fn().mockResolvedValue(1),
countUserSmartScopes: vi.fn().mockResolvedValue(1),
getAccessibleLibraryIds: vi.fn().mockResolvedValue([1]),
getBookManifestPage: vi.fn().mockResolvedValue({ rows: [], hasNext: false }), getBookManifestPage: vi.fn().mockResolvedValue({ rows: [], hasNext: false }),
getRandomBooks: vi.fn().mockResolvedValue([]), getRandomBooks: vi.fn().mockResolvedValue([]),
}; };
@@ -215,6 +219,10 @@ function makeService(
getLibraryVersion: vi.fn().mockResolvedValue('lib-v1'), getLibraryVersion: vi.fn().mockResolvedValue('lib-v1'),
}; };
const browseCountsService = {
getCounts: vi.fn().mockResolvedValue({ authors: 812, series: 96, annotations: 40 }),
};
const service = new KoreaderCatalogService( const service = new KoreaderCatalogService(
opdsBookService as never, opdsBookService as never,
bookService as never, bookService as never,
@@ -222,6 +230,7 @@ function makeService(
userBookStatusService as never, userBookStatusService as never,
dashboardService as never, dashboardService as never,
dashboardWidgetService as never, dashboardWidgetService as never,
browseCountsService as never,
recommendationService as never, recommendationService as never,
{ isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never, { isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(true) } as never,
{ {
@@ -240,6 +249,7 @@ function makeService(
userBookStatusService, userBookStatusService,
dashboardService, dashboardService,
dashboardWidgetService, dashboardWidgetService,
browseCountsService,
recommendationService, recommendationService,
pluginService, pluginService,
}; };
@@ -329,7 +339,7 @@ describe('KoreaderCatalogService', () => {
const dashboard = await service.getDashboard(user); const dashboard = await service.getDashboard(user);
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'recently_read', 1, 5, { readStatus: 'reading' }, false, user.contentFilters); expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'recently_read', 1, 5, { readStatus: 'reading' }, false, user.contentFilters);
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'title_asc', 1, 1, {}, false, user.contentFilters); expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, {}, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters); expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 12, false, user.contentFilters);
expect(dashboard.sections.map((section) => section.id)).toContain('all-books'); expect(dashboard.sections.map((section) => section.id)).toContain('all-books');
expect(dashboard.username).toBe('testuser'); expect(dashboard.username).toBe('testuser');
@@ -346,6 +356,59 @@ describe('KoreaderCatalogService', () => {
expect(dashboardWidgetService.getHighlightOfTheDay).toHaveBeenCalledWith(user); expect(dashboardWidgetService.getHighlightOfTheDay).toHaveBeenCalledWith(user);
}); });
it('carries browse counts for every dashboard tile the server can count', async () => {
const { service, opdsBookService, browseCountsService } = makeService();
const user = makeUser({ id: 7 });
opdsBookService.countBooks.mockResolvedValue(12);
opdsBookService.getAccessibleLibraryIds.mockResolvedValue([1, 2]);
opdsBookService.countUserCollections.mockResolvedValue(1);
opdsBookService.countUserSmartScopes.mockResolvedValue(3);
const dashboard = await service.getDashboard(user);
expect(dashboard.browseCounts).toEqual({
inProgress: 12,
libraries: 2,
authors: 812,
series: 96,
collections: 1,
smartScopes: 3,
});
// Authors and series come from the cached sidebar totals rather than being
// recomputed per dashboard load.
expect(browseCountsService.getCounts).toHaveBeenCalledWith(user);
expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, { readStatus: 'reading' }, false, user.contentFilters);
});
it('counts entities the requesting user can reach, not every row in the table', async () => {
const { service, opdsBookService } = makeService();
const user = makeUser({ id: 7 });
opdsBookService.countBooks.mockResolvedValue(0);
opdsBookService.getAccessibleLibraryIds.mockResolvedValue([]);
opdsBookService.countUserCollections.mockResolvedValue(0);
opdsBookService.countUserSmartScopes.mockResolvedValue(0);
const dashboard = await service.getDashboard(user);
expect(opdsBookService.getAccessibleLibraryIds).toHaveBeenCalledWith(7, false);
expect(opdsBookService.countUserCollections).toHaveBeenCalledWith(7);
expect(opdsBookService.countUserSmartScopes).toHaveBeenCalledWith(7);
// The badge never reads a per-entity book count, so the list methods that
// aggregate them must stay out of the dashboard path entirely.
expect(opdsBookService.getAccessibleLibraries).not.toHaveBeenCalled();
expect(opdsBookService.getUserCollections).not.toHaveBeenCalled();
// Zero has to survive to the wire: the plugin renders it, and an absent
// field is how it detects a server that cannot count at all.
expect(dashboard.browseCounts).toEqual({
inProgress: 0,
libraries: 0,
authors: 812,
series: 96,
collections: 0,
smartScopes: 0,
});
});
function makeEntry(id: number, title: string) { function makeEntry(id: number, title: string) {
return { return {
id, id,
@@ -517,7 +580,7 @@ describe('KoreaderCatalogService', () => {
it('maps section entries to scoped book links and content-filtered counts', async () => { it('maps section entries to scoped book links and content-filtered counts', async () => {
const { service, opdsBookService } = makeService(); const { service, opdsBookService } = makeService();
opdsBookService.getBooksPage.mockResolvedValue({ entries: [], total: 3 }); opdsBookService.countBooks.mockResolvedValue(3);
const user = makeUser({ id: 7 }); const user = makeUser({ id: 7 });
const libraries = await service.getSectionEntries(user, 'libraries'); const libraries = await service.getSectionEntries(user, 'libraries');
@@ -533,7 +596,7 @@ describe('KoreaderCatalogService', () => {
booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&libraryId=1', booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&libraryId=1',
}), }),
); );
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'title_asc', 1, 1, { libraryId: 1 }, false, user.contentFilters); expect(opdsBookService.countBooks).toHaveBeenCalledWith(7, { libraryId: 1 }, false, user.contentFilters);
expect(authors.items[0]!.booksHref).toContain('author=Frank+Herbert'); expect(authors.items[0]!.booksHref).toContain('author=Frank+Herbert');
expect(series.items[0]).toEqual( expect(series.items[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -828,27 +891,18 @@ describe('KoreaderCatalogService', () => {
it('includes a series read-through summary on series-scoped pages', async () => { it('includes a series read-through summary on series-scoped pages', async () => {
const { service, opdsBookService } = makeService(); const { service, opdsBookService } = makeService();
opdsBookService.getBooksPage opdsBookService.getBooksPage.mockResolvedValueOnce({ entries: [], total: 6 });
.mockResolvedValueOnce({ entries: [], total: 6 }) opdsBookService.countBooks.mockResolvedValueOnce(6).mockResolvedValueOnce(2);
.mockResolvedValueOnce({ entries: [], total: 6 })
.mockResolvedValueOnce({ entries: [], total: 2 });
const user = makeUser({ id: 7 }); const user = makeUser({ id: 7 });
const result = await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { seriesId: 42, sort: 'series' })); const result = await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { seriesId: 42, sort: 'series' }));
expect(result.seriesSummary).toEqual({ total: 6, finished: 2 }); expect(result.seriesSummary).toEqual({ total: 6, finished: 2 });
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(1, 7, 'series_asc', 1, 20, { seriesId: 42 }, false, user.contentFilters); expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(1, 7, 'series_asc', 1, 20, { seriesId: 42 }, false, user.contentFilters);
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(2, 7, 'title_asc', 1, 1, { seriesId: 42 }, false, user.contentFilters); // The two summary totals are counted, not paged: neither fetches a row.
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith( expect(opdsBookService.countBooks).toHaveBeenNthCalledWith(1, 7, { seriesId: 42 }, false, user.contentFilters);
3, expect(opdsBookService.countBooks).toHaveBeenNthCalledWith(2, 7, { seriesId: 42, readStatus: 'finished' }, false, user.contentFilters);
7, expect(opdsBookService.getBooksPage).toHaveBeenCalledTimes(1);
'title_asc',
1,
1,
{ seriesId: 42, readStatus: 'finished' },
false,
user.contentFilters,
);
}); });
it('omits the series summary for non-series listings', async () => { it('omits the series summary for non-series listings', async () => {
@@ -11,6 +11,7 @@ import { DEFAULT_KOREADER_DEVICE_PATTERN, resolveUploadPath } from '@bookorbit/t
import type { import type {
KoreaderCatalogBookDetail, KoreaderCatalogBookDetail,
KoreaderCatalogBookListItem, KoreaderCatalogBookListItem,
KoreaderCatalogBrowseCounts,
KoreaderCatalogDashboardResponse, KoreaderCatalogDashboardResponse,
KoreaderCatalogDashboardSection, KoreaderCatalogDashboardSection,
KoreaderCatalogDashboardSectionResponse, KoreaderCatalogDashboardSectionResponse,
@@ -42,6 +43,7 @@ import { contentDispositionHeader } from '../../common/utils/content-disposition
import { storageConfig } from '../../config/config'; 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 { BrowseCountsService } from '../browse-counts/browse-counts.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 { DashboardService } from '../dashboard/dashboard.service';
import { DashboardWidgetService } from '../dashboard/dashboard-widget.service'; import { DashboardWidgetService } from '../dashboard/dashboard-widget.service';
@@ -140,6 +142,7 @@ export class KoreaderCatalogService {
private readonly userBookStatusService: UserBookStatusService, private readonly userBookStatusService: UserBookStatusService,
private readonly dashboardService: DashboardService, private readonly dashboardService: DashboardService,
private readonly dashboardWidgetService: DashboardWidgetService, private readonly dashboardWidgetService: DashboardWidgetService,
private readonly browseCountsService: BrowseCountsService,
private readonly recommendationService: RecommendationService, private readonly recommendationService: RecommendationService,
private readonly appSettingsService: AppSettingsService, private readonly appSettingsService: AppSettingsService,
private readonly koreaderService: KoreaderService, private readonly koreaderService: KoreaderService,
@@ -159,7 +162,7 @@ export class KoreaderCatalogService {
readStatus: 'reading' as const, readStatus: 'reading' as const,
}); });
const [continueReading, discover, dashboardSection, readingGoal, readingStreak, highlightOfTheDay, totalBooks] = await Promise.all([ const [continueReading, discover, dashboardSection, readingGoal, readingStreak, highlightOfTheDay, totalBooks, browseCounts] = await Promise.all([
this.getBooksPage(user, continueReadingQuery), this.getBooksPage(user, continueReadingQuery),
section ? Promise.resolve<KoreaderCatalogBookListItem[]>([]) : this.buildDiscover(user), section ? Promise.resolve<KoreaderCatalogBookListItem[]>([]) : this.buildDiscover(user),
section ? this.buildDashboardSection(user, section) : Promise.resolve(undefined), section ? this.buildDashboardSection(user, section) : Promise.resolve(undefined),
@@ -167,6 +170,7 @@ export class KoreaderCatalogService {
this.dashboardWidgetService.getReadingStreak(user), this.dashboardWidgetService.getReadingStreak(user),
this.dashboardWidgetService.getHighlightOfTheDay(user), this.dashboardWidgetService.getHighlightOfTheDay(user),
this.countBooks(user, {}), this.countBooks(user, {}),
this.buildBrowseCounts(user),
]); ]);
return { return {
@@ -174,6 +178,7 @@ export class KoreaderCatalogService {
username: user.username, username: user.username,
displayName: user.name || user.username, displayName: user.name || user.username,
totalBooks, totalBooks,
browseCounts,
sections: ROOT_SECTIONS.map((rootSection) => ({ ...rootSection })), sections: ROOT_SECTIONS.map((rootSection) => ({ ...rootSection })),
continueReading: continueReading.items, continueReading: continueReading.items,
discover, discover,
@@ -623,6 +628,29 @@ export class KoreaderCatalogService {
return suffix ? `${CATALOG_BASE}/sections/${section}?${suffix}` : `${CATALOG_BASE}/sections/${section}`; return suffix ? `${CATALOG_BASE}/sections/${section}?${suffix}` : `${CATALOG_BASE}/sections/${section}`;
} }
// Badges for the dashboard Browse tiles. Every total here is a count query:
// authors and series reuse the cached ones the web sidebar already asks for,
// and the rest deliberately avoid the list methods behind the section
// endpoints, which aggregate per-entity book counts a badge never reads.
private async buildBrowseCounts(user: RequestUser): Promise<KoreaderCatalogBrowseCounts> {
const [inProgress, sidebarCounts, libraryIds, collections, smartScopes] = await Promise.all([
this.countBooks(user, { readStatus: 'reading' }),
this.browseCountsService.getCounts(user),
this.opdsBookService.getAccessibleLibraryIds(user.id, user.isSuperuser),
this.opdsBookService.countUserCollections(user.id),
this.opdsBookService.countUserSmartScopes(user.id),
]);
return {
inProgress,
libraries: libraryIds.length,
authors: sidebarCounts.authors,
series: sidebarCounts.series,
collections,
smartScopes,
};
}
private async countBooks( private async countBooks(
user: RequestUser, user: RequestUser,
filters: { filters: {
@@ -633,8 +661,7 @@ export class KoreaderCatalogService {
readStatus?: 'unread' | 'reading' | 'finished'; readStatus?: 'unread' | 'reading' | 'finished';
}, },
): Promise<number> { ): Promise<number> {
const result = await this.opdsBookService.getBooksPage(user.id, 'title_asc', 1, 1, filters, user.isSuperuser, user.contentFilters); return this.opdsBookService.countBooks(user.id, filters, user.isSuperuser, user.contentFilters);
return result.total;
} }
private async computeSeriesSummary(user: RequestUser, filters: { series?: string; seriesId?: number }): Promise<KoreaderCatalogSeriesSummary> { private async computeSeriesSummary(user: RequestUser, filters: { series?: string; seriesId?: number }): Promise<KoreaderCatalogSeriesSummary> {
@@ -6,6 +6,7 @@ import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AnnotationModule } from '../annotation/annotation.module'; import { AnnotationModule } from '../annotation/annotation.module';
import { BookmarkModule } from '../bookmark/bookmark.module'; import { BookmarkModule } from '../bookmark/bookmark.module';
import { BookModule } from '../book/book.module'; import { BookModule } from '../book/book.module';
import { BrowseCountsModule } from '../browse-counts/browse-counts.module';
import { DashboardModule } from '../dashboard/dashboard.module'; import { DashboardModule } from '../dashboard/dashboard.module';
import { OpdsModule } from '../opds/opds.module'; import { OpdsModule } from '../opds/opds.module';
import { PositionConverterModule } from '../position-converter/position-converter.module'; import { PositionConverterModule } from '../position-converter/position-converter.module';
@@ -43,6 +44,7 @@ import { KoreaderStatsService } from './koreader-stats.service';
AnnotationModule, AnnotationModule,
BookModule, BookModule,
BookmarkModule, BookmarkModule,
BrowseCountsModule,
DashboardModule, DashboardModule,
OpdsModule, OpdsModule,
PositionConverterModule, PositionConverterModule,
@@ -590,6 +590,31 @@ export class OpdsBookService {
.orderBy(collections.name); .orderBy(collections.name);
} }
// Badge counts only need the totals, so these skip the per-entity book counts
// their list siblings compute. Counting collections through getUserCollections
// would aggregate over collection_books just to read the row count back.
async countUserCollections(userId: number): Promise<number> {
const [row] = await this.db.select({ total: count() }).from(collections).where(eq(collections.userId, userId));
return Number(row?.total ?? 0);
}
async countUserSmartScopes(userId: number): Promise<number> {
const [row] = await this.db
.select({ total: count() })
.from(smartScopes)
.where(or(eq(smartScopes.userId, userId), eq(smartScopes.isPublic, true)));
return Number(row?.total ?? 0);
}
// The scope is shared with getBooksPage so a count can never disagree with the
// list it labels, but nothing is fetched or hydrated to produce it.
async countBooks(userId: number, filters?: OpdsBookFilters, isSuperuser = false, contentFilters?: ContentFilterRules): Promise<number> {
const scope = await this.buildCatalogScope(userId, filters, isSuperuser, contentFilters);
if (!scope) return 0;
const [row] = await this.db.select({ total: count() }).from(books).leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)).where(scope.where);
return Number(row?.total ?? 0);
}
async getUserSmartScopes(userId: number) { async getUserSmartScopes(userId: number) {
return this.db return this.db
.select({ .select({