diff --git a/koreader-plugin/README.md b/koreader-plugin/README.md
index 414c7903f..516d72e01 100644
--- a/koreader-plugin/README.md
+++ b/koreader-plugin/README.md
@@ -2,7 +2,7 @@
Syncs your KOReader reading life into BookOrbit:
-- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search, 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.
+- **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search, built from four configurable sections you choose the sources of. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device.
- **Progress sync**: pulls progress on book open (with a conflict prompt), pushes periodically and on close/suspend.
- **Reading statistics**: uploads KOReader's per-page time events; BookOrbit turns them into reading sessions and daily stats.
- **Two-way highlights**: device highlights appear as native highlights on the web reader; highlights, edits and deletions made on the web come back to the device.
@@ -31,7 +31,7 @@ Re-downloading later (e.g. after changing your password or server address) and r
1. Tools > BookOrbit > "Auto sync current book" to sync the open book automatically. Leave the stock "Progress sync" plugin unconfigured to avoid double syncing.
2. Optional: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
3. Optional: Settings > Dashboard > "Open dashboard on startup".
-4. Optional: 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.
+4. Optional: Settings > Dashboard > Section 1-4 - pick each dashboard section's source: Stats, Continue reading, Discover, Browse, Highlight of the day, Recently added, In progress, Want to read, Up next in series, or a pick from the Libraries/Authors/Series/Collections/SmartScopes catalogs. Want to read and Up next in series need a server new enough to advertise the section capability; against an older server they render empty and stop being offered. "Tap Continue reading to open the book" (on by default) resumes an on-device book in one tap; hold for its actions.
5. Optional: assign "BookOrbit: sync this book" / "sync all books" to gestures.
The plugin manages `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` to keep its menu entry pinned below Calibre across updates; delete those files to reset your menu order.
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.collections.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.collections.svg
new file mode 100644
index 000000000..05ab2c197
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.collections.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.in-progress.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.in-progress.svg
new file mode 100644
index 000000000..017d1c3cc
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.in-progress.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.on-device.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.on-device.svg
new file mode 100644
index 000000000..602919337
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.on-device.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.shuffle.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.shuffle.svg
new file mode 100644
index 000000000..10bd6fb0f
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.shuffle.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-goal.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-goal.svg
new file mode 100644
index 000000000..db3fcbd9a
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-goal.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-streak.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-streak.svg
new file mode 100644
index 000000000..9429de409
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-streak.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-today.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-today.svg
new file mode 100644
index 000000000..97f0639b0
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-today.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-week.svg b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-week.svg
new file mode 100644
index 000000000..21b814c6a
--- /dev/null
+++ b/koreader-plugin/bookorbit.koplugin/assets/bookorbit.stat-week.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
index 962f7b30b..52a28c898 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
@@ -558,6 +558,13 @@ function BookOrbitApi:catalogDiscover()
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
end
+-- One server-composed dashboard section (want-to-read, up-next-in-series).
+-- Only valid against servers advertising the catalogDashboardSections
+-- capability; older ones answer 404.
+function BookOrbitApi:catalogDashboardSection(section_type)
+ return self:request("GET", "/koreader/plugin/catalog/dashboard/sections/" .. util.urlEncode(tostring(section_type)))
+end
+
function BookOrbitApi:catalogSection(section, params)
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
end
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua
index dd68eb9bf..7d77dc59c 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua
@@ -234,6 +234,16 @@ function BookOrbitCatalog:init()
self.client = BookOrbitApi.new(self.api)
self.stack = {}
self.settings = self.settings or {}
+ CatalogWidgets.setAssetIconFiles({
+ on_device = self:pluginAssetFile("bookorbit.on-device.svg"),
+ shuffle = self:pluginAssetFile("bookorbit.shuffle.svg"),
+ in_progress = self:pluginAssetFile("bookorbit.in-progress.svg"),
+ collections = self:pluginAssetFile("bookorbit.collections.svg"),
+ stat_today = self:pluginAssetFile("bookorbit.stat-today.svg"),
+ stat_week = self:pluginAssetFile("bookorbit.stat-week.svg"),
+ stat_streak = self:pluginAssetFile("bookorbit.stat-streak.svg"),
+ stat_goal = self:pluginAssetFile("bookorbit.stat-goal.svg"),
+ })
self:initThumbnailCache()
self:initBulkDownloadState()
self.view_mode = self.settings.catalog_view_mode == "list" and "list" or "mosaic"
@@ -276,12 +286,15 @@ function BookOrbitCatalog:init()
end)
end
-function BookOrbitCatalog:downloadIconPath()
+function BookOrbitCatalog:pluginAssetFile(name)
local source_dir = self.path or (self._manager and self._manager.path)
- if source_dir then
- local path = source_dir .. "/assets/" .. DOWNLOAD_ICON_FILE
- if lfs.attributes(path, "mode") == "file" then return path end
- end
+ if not source_dir then return nil end
+ local path = source_dir .. "/assets/" .. name
+ if lfs.attributes(path, "mode") == "file" then return path end
+end
+
+function BookOrbitCatalog:downloadIconPath()
+ return self:pluginAssetFile(DOWNLOAD_ICON_FILE)
end
function BookOrbitCatalog:isBulkSelectionActive()
@@ -561,6 +574,16 @@ function BookOrbitCatalog:isOnDevice(book)
return book ~= nil and book.id ~= nil and self.on_device[book.id] ~= nil
end
+-- The local file a Continue-reading tap should resume: only when the
+-- tap-to-resume setting is on and the book is already on the device. Nil sends
+-- the tap to the detail page instead.
+function BookOrbitCatalog:dashboardResumePath(book)
+ if self.settings.catalog_dashboard_tap_resumes == false then return nil end
+ local path = book ~= nil and book.id ~= nil and self.on_device[book.id] or nil
+ if type(path) == "string" and path ~= "" then return path end
+ return nil
+end
+
function BookOrbitCatalog:onDeviceFilePath(file)
return file ~= nil and file.id ~= nil and self.on_device_files and self.on_device_files[file.id] or nil
end
@@ -1890,7 +1913,15 @@ end
function BookOrbitCatalog:finishCustomUpdate(old_dimen, select_number)
self:updatePageInfo(select_number)
Menu.mergeTitleBarIntoLayout(self)
+ -- A view may narrow the e-ink refresh to the region it actually changed
+ -- (the dashboard does for row page turns); everything else refreshes the
+ -- whole menu as before.
+ local refresh_region = self.custom_refresh_region
+ self.custom_refresh_region = nil
UIManager:setDirty(self.show_parent, function()
+ if refresh_region then
+ return "ui", refresh_region
+ end
local refresh_dimen = old_dimen and old_dimen:combine(self.dimen) or self.dimen
return "ui", refresh_dimen
end)
@@ -2161,7 +2192,14 @@ function BookOrbitCatalog:onMenuSelect(item)
if item.kind == "section" then
self:loadSection(item.section)
elseif item.kind == "dashboard-book" then
- self:loadBookDetail(item.book_id)
+ -- Continue-reading heroes resume the book directly when it is on the
+ -- device; hold still opens the full action sheet.
+ local resume_path = item.resume and self:dashboardResumePath(item.book) or nil
+ if resume_path then
+ self:openDownloadedFile(resume_path)
+ else
+ self:loadBookDetail(item.book_id)
+ end
elseif item.kind == "dashboard-highlight" then
self:loadBookDetail(item.book_id)
elseif item.kind == "dashboard-search" then
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua
index 20f0e1988..18bf6c92e 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_dashboard.lua
@@ -27,12 +27,14 @@ local NetworkMgr = require("ui/network/manager")
local Screen = require("device").screen
local Size = require("ui/size")
local TextBoxWidget = require("ui/widget/textboxwidget")
+local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local T = require("ffi/util").template
local _ = require("gettext")
+local Capabilities = require("bookorbit_capabilities")
local CatalogUtil = require("bookorbit_catalog_util")
local CatalogWidgets = require("bookorbit_catalog_widgets")
local DashboardSections = require("bookorbit_dashboard_sections")
@@ -40,10 +42,12 @@ local BookOrbitStatsReader = require("bookorbit_stats_reader")
local formatDuration = CatalogUtil.formatDuration
local formatProgress = CatalogUtil.formatProgress
+local formatRelativeTime = CatalogUtil.formatRelativeTime
local readingStreakDays = CatalogUtil.readingStreakDays
local BookOrbitDashboardCoverCard = CatalogWidgets.DashboardCoverCard
local BookOrbitDashboardHeroCard = CatalogWidgets.DashboardHeroCard
+local BookOrbitDashboardHighlightCard = CatalogWidgets.DashboardHighlightCard
local BookOrbitDashboardBrowseRow = CatalogWidgets.DashboardBrowseRow
local BookOrbitDashboardIconButton = CatalogWidgets.DashboardIconButton
@@ -59,6 +63,20 @@ local SECTION_PAGE_PREFIX = "section"
local SHELF_PAGE_SIZE = 12
-- Cover cards never get narrower than this, however tight the page is.
local SHELF_MIN_CARD_WIDTH = 72
+-- Header controls are drawn inside the section header's label row, so the row
+-- stays as tall as a header without controls; the tap box reaches into the gap
+-- above and below it to stay finger-sized.
+local HEADER_CONTROL_ICON = 19
+local HEADER_CONTROL_TAP_PAD = 9
+-- The seven-day activity chart inside the stats strip.
+local WEEK_BAR_MAX_HEIGHT = 18
+local WEEK_BAR_WIDTH = 5
+local WEEK_BAR_GAP = 3
+-- Breathing room above and below the stats blocks, inside the strip's rules.
+local STATS_BODY_PADDING = 6
+-- The marker a stats block shows between its value and its label when it has
+-- no sparkline of its own.
+local STATS_ICON_SIZE = 20
local CatalogDashboard = {}
@@ -105,13 +123,23 @@ function CatalogDashboard.dashboardItems()
}
end
+local function greetingForHour(hour)
+ if hour >= 5 and hour < 12 then
+ return _("Good morning"), _("Good morning, %1")
+ elseif hour >= 12 and hour < 18 then
+ return _("Good afternoon"), _("Good afternoon, %1")
+ end
+ return _("Good evening"), _("Good evening, %1")
+end
+
function CatalogDashboard:dashboardSubtitle(dashboard)
local username = dashboard and (dashboard.displayName or dashboard.name or dashboard.username)
or (self.settings and self.settings.username)
+ local plain, templated = greetingForHour(tonumber(os.date("%H")) or 12)
if type(username) == "string" and username ~= "" then
- return T(_("Hi, %1"), username)
+ return T(templated, username)
end
- return _("Hi")
+ return plain
end
function CatalogDashboard:dashboardContext(dashboard, opts)
@@ -193,6 +221,28 @@ function CatalogDashboard:dashboardRoot(opts)
end
return { type = config.type, books = response.discover or {} }
end
+ -- Sources without a catalog-books equivalent go through the server's
+ -- dashboard-section endpoint. A confirmed 404/405 downgrades the
+ -- capability so the picker stops offering them against this server.
+ if DashboardSections.usesSectionEndpoint(config.type) then
+ local known = Capabilities.cached(self.client)
+ if known and not known[DashboardSections.SECTION_ENDPOINT_CAPABILITY] then
+ return { type = config.type, books = {} }
+ end
+ local response, section_err = self:fetch(_("Loading dashboard..."), function()
+ return self.client:catalogDashboardSection(config.type)
+ end, opts)
+ if isDashboardUnsupported(section_err) then
+ Capabilities.markUnsupported(self.client, DashboardSections.SECTION_ENDPOINT_CAPABILITY)
+ return { type = config.type, books = {} }
+ end
+ if section_err or type(response) ~= "table" then
+ return nil, section_err or "invalid response"
+ end
+ local section = type(response.section) == "table" and response.section or {}
+ return { type = config.type, books = section.books or {} }
+ end
+
local params = { page = 1, size = SHELF_PAGE_SIZE }
if config.type == "recently-added" then
params.sort = "recently_added"
@@ -293,11 +343,12 @@ function CatalogDashboard.dashboardBooks(dashboard)
return books
end
-function CatalogDashboard:dashboardActionEntries()
+function CatalogDashboard:dashboardActionEntries(total_books)
return {
{
text = _("In progress"),
icon = "dogear.reading",
+ icon_file = CatalogWidgets.assetIconFile("in_progress"),
kind = "books",
params = { sort = "recently_read", readStatus = "reading" },
},
@@ -316,6 +367,7 @@ function CatalogDashboard:dashboardActionEntries()
{
text = _("All Books"),
icon = "appbar.pageview",
+ mandatory = total_books and tostring(total_books) or nil,
kind = "books",
params = { sort = "title" },
},
@@ -334,6 +386,7 @@ function CatalogDashboard:dashboardActionEntries()
{
text = _("Collections"),
icon = "texture-box",
+ icon_file = CatalogWidgets.assetIconFile("collections"),
kind = "section",
section = "collections",
},
@@ -343,6 +396,11 @@ function CatalogDashboard:dashboardActionEntries()
kind = "section",
section = "smart-scopes",
},
+ {
+ text = _("Search"),
+ icon = "appbar.search",
+ kind = "dashboard-search",
+ },
}
end
@@ -371,20 +429,25 @@ end
-- thick underline running into a hairline rule. Controls (paging chevrons,
-- the Discover reroll) are pinned to the header's right edge so the rows
-- below keep the full content width and stay aligned with the margins.
-function CatalogDashboard:addDashboardHeader(text, controls)
+-- focus_controls names the tappable subset when controls also carries
+-- display-only widgets such as the page indicator.
+function CatalogDashboard:addDashboardHeader(text, controls, focus_controls)
local right
if controls and #controls > 0 then
right = HorizontalGroup:new{ align = "center" }
for index, control in ipairs(controls) do
if index > 1 then
- table.insert(right, HorizontalSpan:new{ width = Size.span.horizontal_default })
+ -- The control boxes already carry padding around their glyphs;
+ -- a wide span on top of that scatters the cluster.
+ table.insert(right, HorizontalSpan:new{ width = Size.span.horizontal_small })
end
table.insert(right, control)
end
end
self:addDashboardInset(CatalogWidgets.buildDashboardSectionHeader(text, self.content_w, right))
- if controls and #controls > 0 then
- table.insert(self.layout, controls)
+ local focus_row = focus_controls or controls
+ if focus_row and #focus_row > 0 then
+ table.insert(self.layout, focus_row)
end
end
@@ -394,34 +457,51 @@ function CatalogDashboard:dashboardSectionSupportsReroll(config)
return (config or {}).type == DashboardSections.DEFAULT_TYPE
end
-function CatalogDashboard:buildDashboardRerollButton(index)
- local size = Screen:scaleBySize(24)
+-- Every header control is a square the size of the header's label row, so the
+-- controls fill that row rather than making the header taller than one without
+-- them.
+function CatalogDashboard:buildDashboardHeaderControl(opts)
+ local size = CatalogWidgets.dashboardSectionHeaderRowHeight()
return BookOrbitDashboardIconButton:new{
- entry = { kind = "dashboard-reroll", section_index = index or 1, icon = "cre.render.reload" },
+ entry = opts.entry,
dimen = Geom:new{ x = 0, y = 0, w = size, h = size },
+ icon_size = Screen:scaleBySize(HEADER_CONTROL_ICON),
+ tap_padding_v = Screen:scaleBySize(HEADER_CONTROL_TAP_PAD),
+ enabled = opts.enabled,
+ callback = opts.callback,
menu = self,
}
end
--- A pair of small borderless paging chevrons for a section header.
+function CatalogDashboard:buildDashboardRerollButton(index)
+ return self:buildDashboardHeaderControl{
+ entry = {
+ kind = "dashboard-reroll",
+ section_index = index or 1,
+ icon = "cre.render.reload",
+ icon_file = CatalogWidgets.assetIconFile("shuffle"),
+ },
+ }
+end
+
+-- Paging chevrons for a section header, with a "page/pages" indicator between
+-- them so a shelf's extent is visible.
function CatalogDashboard:buildDashboardHeaderNav(section_id, page, page_count)
- local size = Screen:scaleBySize(24)
local function navButton(icon, enabled, delta)
- return Button:new{
- icon = icon,
- icon_width = Screen:scaleBySize(12),
- icon_height = Screen:scaleBySize(12),
- width = size,
- height = size,
- bordersize = 0,
- show_border = false,
+ return self:buildDashboardHeaderControl{
+ entry = { icon = icon },
enabled = enabled,
callback = function()
self:turnDashboardPage(section_id, delta)
end,
}
end
- return navButton("chevron.left", page > 1, -1), navButton("chevron.right", page < page_count, 1)
+ local indicator = TextWidget:new{
+ text = tostring(page) .. "/" .. tostring(page_count),
+ face = Font:getFace("cfont", 12),
+ fgcolor = Blitbuffer.COLOR_DARK_GRAY,
+ }
+ return navButton("chevron.left", page > 1, -1), navButton("chevron.right", page < page_count, 1), indicator
end
function CatalogDashboard:dashboardPage(section_id, page_count)
@@ -437,6 +517,9 @@ function CatalogDashboard:turnDashboardPage(section_id, delta)
local context = self.current_context or {}
context.dash_pages = context.dash_pages or {}
context.dash_pages[section_id] = math.max(1, (tonumber(context.dash_pages[section_id]) or 1) + delta)
+ -- A page turn changes one section band and nothing else, so only that band
+ -- needs an e-ink refresh; the rebuild below records the band's position.
+ self.dash_refresh_section = section_id
self:updateItems(nil, true)
end
@@ -449,6 +532,10 @@ function CatalogDashboard:dashboardHeroMetaText(book)
local status = self:readStatusLabel(book)
if status then table.insert(parts, status) end
end
+ local recency = formatRelativeTime(book.lastReadAt)
+ if recency then
+ table.insert(parts, recency)
+ end
if self:isOnDevice(book) then
table.insert(parts, _("On device"))
end
@@ -461,6 +548,7 @@ function CatalogDashboard:buildDashboardHeroCard(book, width, height)
kind = "dashboard-book",
book_id = book.id,
book = book,
+ resume = true,
meta_text = self:dashboardHeroMetaText(book),
},
dimen = Geom:new{ x = 0, y = 0, w = width, h = height },
@@ -620,6 +708,21 @@ function CatalogDashboard:addDashboardEmptyState(text, button_text, callback)
table.insert(self.layout, { button })
end
+-- A muted status line shown while the dashboard renders cached data, built
+-- from the generatedAt timestamp the cached body carries.
+function CatalogDashboard:buildDashboardStatusLine(dashboard)
+ local updated = formatRelativeTime(dashboard and dashboard.generatedAt)
+ local text
+ if NetworkMgr:isConnected() then
+ text = updated and T(_("Showing cached dashboard - updated %1"), updated)
+ or _("Showing cached dashboard")
+ else
+ text = updated and T(_("Offline - updated %1"), updated)
+ or _("Offline - showing cached dashboard")
+ end
+ return CatalogWidgets.buildStatusLabel(text, self.content_w, nil, "center")
+end
+
-- Local reading activity from statistics.sqlite3, cached briefly so row page
-- turns do not reopen the database. The local streak is retained as a fallback
-- for cached responses from servers that do not provide the account streak.
@@ -638,37 +741,117 @@ function CatalogDashboard:dashboardStatsSummary()
return summary
end
--- Three plain stat blocks separated by hairlines: no boxes, so the strip
--- reads as information rather than competing with the tappable cards.
+-- The last seven days as bar heights, oldest first, normalized to the busiest
+-- day. Falls back to the account streak's read/not-read days when local
+-- statistics carry nothing, and to nil when neither side has data.
+local function weekBarData(summary, dashboard)
+ local day_seconds = type(summary.day_seconds) == "table" and summary.day_seconds or {}
+ local values, max_value = {}, 0
+ for day = 1, 7 do
+ local value = tonumber(day_seconds[day]) or 0
+ values[day] = value
+ max_value = math.max(max_value, value)
+ end
+ if max_value > 0 then return values, max_value end
+ local reading_streak = dashboard and dashboard.readingStreak
+ local last_seven = type(reading_streak) == "table" and reading_streak.lastSevenDays or nil
+ if type(last_seven) ~= "table" then return nil end
+ local any = false
+ for day = 1, 7 do
+ values[day] = last_seven[day] and 1 or 0
+ any = any or values[day] > 0
+ end
+ if not any then return nil end
+ return values, 1
+end
+
+-- A seven-day activity chart under the "Past 7 days" value: a bar per day
+-- scaled against the busiest one and standing on a shared baseline, today
+-- solid black and earlier days dark gray.
+function CatalogDashboard:buildDashboardWeekBars(summary, dashboard)
+ local values, max_value = weekBarData(summary, dashboard)
+ if not values then return nil end
+ local max_h = Screen:scaleBySize(WEEK_BAR_MAX_HEIGHT)
+ local min_fill = math.max(1, Screen:scaleBySize(2))
+ local bar_w = Screen:scaleBySize(WEEK_BAR_WIDTH)
+ local gap = Screen:scaleBySize(WEEK_BAR_GAP)
+ local row = HorizontalGroup:new{ align = "bottom" }
+ for day = 1, 7 do
+ if day > 1 then
+ table.insert(row, HorizontalSpan:new{ width = gap })
+ end
+ local fill_h = values[day] > 0
+ and math.max(min_fill, math.floor(max_h * values[day] / max_value + 0.5))
+ or 0
+ table.insert(row, CatalogWidgets.buildDashboardWeekBar(bar_w, max_h, fill_h, day == 7))
+ end
+ return CatalogWidgets.buildDashboardWeekChart(row)
+end
+
+-- Activity blocks separated by hairlines: no boxes, so the strip reads as
+-- information rather than competing with the tappable cards. Inventory counts
+-- live in the Browse block; the strip keeps to reading activity plus the
+-- yearly goal when one is set.
function CatalogDashboard:buildDashboardStatsStrip(summary, dashboard, height)
local sep_w = Size.line.thin
- local today_seconds = summary.today_seconds or 0
local streak_days = readingStreakDays(dashboard, summary.streak_days)
+ local week_bars = self:buildDashboardWeekBars(summary, dashboard)
+ -- The blocks without a sparkline take an icon in that row instead, so the
+ -- strip reads as four equally furnished blocks rather than one chart and
+ -- three gaps.
+ local icon_size = Screen:scaleBySize(STATS_ICON_SIZE)
local blocks = {
- { value = today_seconds > 0 and formatDuration(today_seconds) or _("Not yet"), label = _("Today") },
- { value = formatDuration(summary.week_seconds or 0), label = _("Past 7 days") },
- { value = tostring(streak_days), label = _("Day streak") },
+ {
+ value = formatDuration(summary.today_seconds or 0),
+ label = _("Today"),
+ extra = CatalogWidgets.buildDashboardStatIcon("stat_today", icon_size),
+ },
+ {
+ value = formatDuration(summary.week_seconds or 0),
+ label = _("Past 7 days"),
+ -- No per-day data to chart (a fresh install, or a server without
+ -- the streak) still gets a marker rather than the lone gap.
+ extra = week_bars or CatalogWidgets.buildDashboardStatIcon("stat_week", icon_size),
+ },
+ {
+ value = tostring(streak_days),
+ label = _("Day streak"),
+ extra = CatalogWidgets.buildDashboardStatIcon("stat_streak", icon_size),
+ },
}
- local total = dashboard and tonumber(dashboard.totalBooks or dashboard.bookCount)
- if total then
- table.insert(blocks, { value = tostring(total), label = _("Library") })
+ local reading_goal = dashboard and type(dashboard.readingGoal) == "table" and dashboard.readingGoal or nil
+ local goal_books = reading_goal and tonumber(reading_goal.goalBooks) or nil
+ if goal_books and goal_books > 0 then
+ table.insert(blocks, {
+ value = tostring(tonumber(reading_goal.completedBooks) or 0) .. " / " .. tostring(goal_books),
+ label = reading_goal.year and T(_("%1 goal"), reading_goal.year) or _("Yearly goal"),
+ extra = CatalogWidgets.buildDashboardStatIcon("stat_goal", icon_size),
+ })
end
- table.insert(blocks, { value = tostring(self:onDeviceCount()), label = _("On device") })
+ -- One set of row heights for every block, so the values sit on one baseline
+ -- and the labels on another whether a block carries the sparkline or an icon.
+ local marker_h = week_bars and week_bars:getSize().h or 0
+ for _, block in ipairs(blocks) do
+ if block.extra then marker_h = math.max(marker_h, block.extra:getSize().h) end
+ end
+ local metrics = CatalogWidgets.dashboardStatMetrics(marker_h)
local block_w = math.floor((self.content_w - (#blocks - 1) * sep_w) / #blocks)
local row = HorizontalGroup:new{ align = "center" }
for index, block in ipairs(blocks) do
if index > 1 then
table.insert(row, LineWidget:new{
background = Blitbuffer.COLOR_LIGHT_GRAY,
- dimen = Geom:new{ w = sep_w, h = Screen:scaleBySize(18) },
+ dimen = Geom:new{ w = sep_w, h = metrics.total_h },
})
end
- table.insert(row, CatalogWidgets.buildDashboardStat(block.value, block.label, block_w))
+ table.insert(row, CatalogWidgets.buildDashboardStat(
+ block.value, block.label, block_w, block.extra, metrics))
end
local line_h = Size.line.thin
- local body_h = height and math.max(row:getSize().h, height - 2 * line_h)
- or math.max(row:getSize().h, Screen:scaleBySize(STATS_MIN_BODY_HEIGHT))
+ local padded_h = row:getSize().h + 2 * Screen:scaleBySize(STATS_BODY_PADDING)
+ local body_h = height and math.max(padded_h, height - 2 * line_h)
+ or math.max(padded_h, Screen:scaleBySize(STATS_MIN_BODY_HEIGHT))
return VerticalGroup:new{
align = "center",
LineWidget:new{
@@ -743,18 +926,20 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local context = self.current_context or {}
local dashboard = context.dashboard or {}
local continue_books = dashboard.continueReading or {}
- local action_entries = self:dashboardActionEntries()
+ local action_entries = self:dashboardActionEntries(tonumber(dashboard.totalBooks or dashboard.bookCount))
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 highlight = type(dashboard.highlightOfTheDay) == "table" and dashboard.highlightOfTheDay or nil
local function px(n) return Screen:scaleBySize(n) end
local avail = self.available_height
local inner_gap = px(9)
local top_gap = px(4)
- local section_gap = px(14)
+ local section_gap = px(18)
local compact_gap = px(SECTION_COMPACT_GAP)
self.dash_inner_gap = inner_gap
self.dash_used = 0
+ self.dash_section_bands = {}
local header_h = CatalogWidgets.buildDashboardSectionHeader("X", self.content_w):getSize().h
local browse_row_h = px(42)
@@ -762,21 +947,28 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local browse_rows = 3
local hero_h = math.min(px(150), math.max(px(76), math.floor(avail * 0.18)))
+ local status_widget
+ if context.stale and not context.loading and context.dashboard then
+ status_widget = self:buildDashboardStatusLine(dashboard)
+ end
+ local status_h = status_widget and (status_widget:getSize().h + compact_gap) or 0
+
local configs = {}
local stats_widgets = {}
+ local highlight_heights = {}
local hidden = {}
for index = 1, DashboardSections.SLOT_COUNT do
configs[index] = DashboardSections.at(self.settings, index)
end
local function isShelf(config)
- return config.type ~= "stats" and config.type ~= "continue-reading" and config.type ~= "browse"
+ return DashboardSections.isBookSource(config.type)
end
-- Slots with a native renderer take the height they need; the shelves share
-- whatever is left of the page.
local function measureFixed()
- local fixed = top_gap
+ local fixed = top_gap + status_h
local shelf_count = 0
local max_shelf_books = 0
for index = 1, DashboardSections.SLOT_COUNT do
@@ -790,6 +982,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
fixed = fixed + header_h + inner_gap + hero_h + section_gap
elseif config.type == "browse" then
fixed = fixed + header_h + inner_gap + browse_rows * browse_row_h + section_gap
+ elseif config.type == "highlight" then
+ highlight_heights[index] = highlight_heights[index]
+ or (highlight and CatalogWidgets.dashboardHighlightCardHeight(highlight, self.content_w) or px(48))
+ fixed = fixed + header_h + inner_gap + highlight_heights[index] + section_gap
else
shelf_count = shelf_count + 1
max_shelf_books = math.max(max_shelf_books, #self.dashboardSlotBooks(dashboard, index))
@@ -815,11 +1011,15 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
-- Too many slots for the page: the stats strip goes first, since it only
- -- summarises what the reader can reach elsewhere, then shelves bottom-up.
+ -- summarises what the reader can reach elsewhere, then the highlight card,
+ -- then shelves bottom-up.
local function slotToDrop()
for index = 1, DashboardSections.SLOT_COUNT do
if not hidden[index] and configs[index].type == "stats" then return index end
end
+ for index = 1, DashboardSections.SLOT_COUNT do
+ if not hidden[index] and configs[index].type == "highlight" then return index end
+ end
for index = DashboardSections.SLOT_COUNT, 1, -1 do
if not hidden[index] and isShelf(configs[index]) then return index end
end
@@ -833,6 +1033,10 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
self:addDashboardSpacer(top_gap)
+ if status_widget then
+ self:addDashboardInset(status_widget)
+ self:addDashboardSpacer(compact_gap)
+ end
local function renderStats(index)
self:addDashboardInset(stats_widgets[index])
end
@@ -842,12 +1046,14 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local hero_pages = #continue_books > 0 and math.max(1, math.ceil(#continue_books / hero_slots)) or 0
local page_id = "continue" .. tostring(index)
local hero_page = hero_pages > 0 and self:dashboardPage(page_id, hero_pages) or 1
- local controls
+ local band_start = self.dash_used
+ local controls, focus_controls
if hero_pages > 1 then
- local prev_button, next_button = self:buildDashboardHeaderNav(page_id, hero_page, hero_pages)
- controls = { prev_button, next_button }
+ local prev_button, next_button, indicator = self:buildDashboardHeaderNav(page_id, hero_page, hero_pages)
+ controls = { prev_button, indicator, next_button }
+ focus_controls = { prev_button, next_button }
end
- self:addDashboardHeader(DashboardSections.headerText(config), controls)
+ self:addDashboardHeader(DashboardSections.headerText(config), controls, focus_controls)
self:addDashboardSpacer(inner_gap)
if #continue_books > 0 then
self:addDashboardHeroRow(continue_books, hero_h, hero_slots, hero_page)
@@ -860,6 +1066,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self:onMenuSelect({ text = _("All Books"), kind = "books", params = { sort = "title" } })
end)
end
+ self.dash_section_bands[page_id] = { y = band_start, h = self.dash_used - band_start }
end
local function renderBrowse(config)
@@ -868,6 +1075,24 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
self:addDashboardBrowseList(action_entries, browse_row_h, browse_cols, browse_rows)
end
+ local function renderHighlight(index, config)
+ self:addDashboardHeader(DashboardSections.headerText(config))
+ self:addDashboardSpacer(inner_gap)
+ if highlight then
+ local card_h = highlight_heights[index]
+ or CatalogWidgets.dashboardHighlightCardHeight(highlight, self.content_w)
+ self:addDashboardInset(BookOrbitDashboardHighlightCard:new{
+ entry = { kind = "dashboard-highlight", book_id = highlight.bookId, highlight = highlight },
+ dimen = Geom:new{ x = 0, y = 0, w = self.content_w, h = card_h },
+ menu = self,
+ }, highlight.bookId ~= nil)
+ elseif context.loading then
+ self:addDashboardEmptyState(_("Loading..."))
+ else
+ self:addDashboardEmptyState(_("No highlights yet."))
+ end
+ end
+
local function renderShelf(index, config)
local page_id = SECTION_PAGE_PREFIX .. tostring(index)
local pending = stale_sections[index] == true
@@ -875,16 +1100,23 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
local slots = math.max(1, math.min(shelf_slots, #books))
local pages = math.max(1, math.ceil(#books / slots))
local page = self:dashboardPage(page_id, pages)
+ local band_start = self.dash_used
local controls = {}
+ local focus_controls = {}
if pages > 1 then
- local prev_button, next_button = self:buildDashboardHeaderNav(page_id, page, pages)
+ local prev_button, next_button, indicator = self:buildDashboardHeaderNav(page_id, page, pages)
table.insert(controls, prev_button)
+ table.insert(controls, indicator)
table.insert(controls, next_button)
+ table.insert(focus_controls, prev_button)
+ table.insert(focus_controls, next_button)
end
if not pending and self:dashboardSectionSupportsReroll(config) then
- table.insert(controls, self:buildDashboardRerollButton(index))
+ local reroll_button = self:buildDashboardRerollButton(index)
+ table.insert(controls, reroll_button)
+ table.insert(focus_controls, reroll_button)
end
- self:addDashboardHeader(DashboardSections.headerText(config), controls)
+ self:addDashboardHeader(DashboardSections.headerText(config), controls, focus_controls)
self:addDashboardSpacer(inner_gap)
if pending or context.loading then
self:addDashboardEmptyState(_("Loading..."))
@@ -893,6 +1125,7 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
else
self:addDashboardCoverGrid(page_id, books, shelf_row_h, false, false, slots, shelf_card_w, page, 1)
end
+ self.dash_section_bands[page_id] = { y = band_start, h = self.dash_used - band_start }
end
for index = 1, DashboardSections.SLOT_COUNT do
@@ -904,6 +1137,8 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
renderContinueReading(index, config)
elseif config.type == "browse" then
renderBrowse(config)
+ elseif config.type == "highlight" then
+ renderHighlight(index, config)
else
renderShelf(index, config)
end
@@ -912,6 +1147,24 @@ function CatalogDashboard:updateDashboardItems(select_number, no_recalculate_dim
end
self:addDashboardSpacer(math.max(0, avail - self.dash_used))
+
+ -- A page turn recorded which band changed; hand its rect to the refresh so
+ -- the rest of the page keeps its e-ink state. Anything unexpected falls
+ -- back to the full-region refresh.
+ local refresh_section = self.dash_refresh_section
+ self.dash_refresh_section = nil
+ local band = refresh_section and self.dash_section_bands[refresh_section] or nil
+ if band and band.h > 0 and self.dimen and self.menuChromeHeight then
+ local top_height = self:menuChromeHeight()
+ local pad = px(4)
+ local band_y = self.dimen.y + top_height + math.max(0, band.y - pad)
+ self.custom_refresh_region = Geom:new{
+ x = self.dimen.x,
+ y = band_y,
+ w = self.dimen.w,
+ h = band.h + 2 * pad,
+ }
+ end
self:finishCustomUpdate(old_dimen, select_number)
end
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua
index 11d4c0189..f87e51c9d 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua
@@ -138,6 +138,55 @@ function CatalogUtil.formatProgress(value)
return tostring(math.floor(value + 0.5)) .. "%"
end
+-- Parses an ISO-8601 UTC timestamp ("2026-07-30T12:34:56.000Z") to an epoch,
+-- or nil for anything unparseable. os.time reads the fields as local time, so
+-- the current UTC offset is added back.
+local function parseIsoTimestamp(value)
+ if type(value) ~= "string" then return nil end
+ local year, month, day, hour, min, sec = value:match("^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)")
+ if not year then return nil end
+ local as_local = os.time({
+ year = tonumber(year),
+ month = tonumber(month),
+ day = tonumber(day),
+ hour = tonumber(hour),
+ min = tonumber(min),
+ sec = tonumber(sec),
+ })
+ if not as_local then return nil end
+ -- The probe table must infer DST exactly like the parsed table above did
+ -- (os.date("!*t") pins isdst=false, which skews zones currently in DST by
+ -- an hour), so both conversions share the same offset.
+ local probe = os.date("!*t")
+ probe.isdst = nil
+ local offset = os.time() - os.time(probe)
+ return as_local + offset
+end
+
+-- A coarse relative label for a UTC timestamp: "just now", "12 min ago",
+-- "3 h ago", "yesterday", "5 days ago". Nil when the value cannot be parsed.
+function CatalogUtil.formatRelativeTime(value, now)
+ local timestamp = parseIsoTimestamp(value)
+ if not timestamp then return nil end
+ now = now or os.time()
+ local delta = now - timestamp
+ if delta < 0 then delta = 0 end
+ if delta < 60 then
+ return _("just now")
+ end
+ if delta < 3600 then
+ return T(_("%1 min ago"), math.floor(delta / 60))
+ end
+ if delta < 86400 then
+ return T(_("%1 h ago"), math.floor(delta / 3600))
+ end
+ local days = math.floor(delta / 86400)
+ if days == 1 then
+ return _("yesterday")
+ end
+ return T(_("%1 days ago"), days)
+end
+
function CatalogUtil.readingStreakDays(dashboard, fallback)
local reading_streak = dashboard and dashboard.readingStreak
local current_streak = reading_streak and tonumber(reading_streak.currentStreak)
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua
index c4d624117..e126f4d85 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_widgets.lua
@@ -54,6 +54,19 @@ local READ_STATUS_BADGE_ICONS = {
local CatalogWidgets = {}
+-- Absolute paths to the plugin's own SVG icons, resolved once by the catalog
+-- (widgets cannot know the plugin directory). A missing entry falls back to
+-- the stock KOReader glyph named alongside it.
+local asset_icon_files = {}
+
+function CatalogWidgets.setAssetIconFiles(files)
+ asset_icon_files = type(files) == "table" and files or {}
+end
+
+function CatalogWidgets.assetIconFile(name)
+ return asset_icon_files[name]
+end
+
local function hasProgress(book)
return book and book.progressPercentage and book.progressPercentage > 0
end
@@ -110,16 +123,17 @@ function CatalogWidgets.buildProgressBar(percentage, width)
return bar
end
-function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
- local inner_w = math.max(1, width - 2 * Size.padding.default - 2 * Size.border.thin)
- local inner_h = math.max(1, height - 2 * Size.padding.default - 2 * Size.border.thin)
+function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet, bordersize)
+ bordersize = bordersize or Size.border.thin
+ local inner_w = math.max(1, width - 2 * Size.padding.default - 2 * bordersize)
+ local inner_h = math.max(1, height - 2 * Size.padding.default - 2 * bordersize)
if quiet then
return FrameContainer:new{
width = width,
height = height,
margin = 0,
padding = Size.padding.default,
- bordersize = Size.border.thin,
+ bordersize = bordersize,
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
@@ -170,7 +184,7 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
height = height,
margin = 0,
padding = Size.padding.default,
- bordersize = Size.border.thin,
+ bordersize = bordersize,
background = Blitbuffer.COLOR_WHITE,
CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
@@ -179,18 +193,29 @@ function CatalogWidgets.buildFakeCover(book, width, height, footer, quiet)
}
end
-function CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet_placeholder)
+-- opts.no_border drops the cover's own outline for callers that already draw
+-- one around it (the dashboard cards), so a card is not ringed twice.
+-- opts.quiet_placeholder swaps the titled fake cover for a bare one while a
+-- cover is still downloading.
+function CatalogWidgets.buildCoverWidget(book, width, height, path, state, opts)
+ opts = opts or {}
+ local bordersize = opts.no_border and 0 or Size.border.thin
if path then
- return CenterContainer:new{
- dimen = Geom:new{ w = width, h = height },
- FrameContainer:new{
- margin = 0,
- padding = 0,
- bordersize = Size.border.thin,
+ local inner_w = math.max(1, width - 2 * bordersize)
+ local inner_h = math.max(1, height - 2 * bordersize)
+ return FrameContainer:new{
+ width = width,
+ height = height,
+ margin = 0,
+ padding = 0,
+ bordersize = bordersize,
+ background = Blitbuffer.COLOR_WHITE,
+ CenterContainer:new{
+ dimen = Geom:new{ w = inner_w, h = inner_h },
ImageWidget:new{
file = path,
- width = width,
- height = height,
+ width = inner_w,
+ height = inner_h,
scale_factor = 0,
},
},
@@ -205,38 +230,61 @@ function CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet
else
footer = _("No cover")
end
- return CatalogWidgets.buildFakeCover(book, width, height, footer, quiet_placeholder == true)
+ -- Quiet placeholders are reserved for covers still loading; a cover that is
+ -- missing or failed keeps the titled fake cover so the book stays
+ -- identifiable on caption-less shelves.
+ return CatalogWidgets.buildFakeCover(
+ book, width, height, footer, opts.quiet_placeholder == true and state == "loading", bordersize)
+end
+
+-- Badges sit on a small white plate so they stay legible over busy cover art;
+-- a bare glyph disappears against dark covers on e-ink.
+local function badgeChip(icon_widget)
+ return FrameContainer:new{
+ margin = 0,
+ padding = Size.padding.tiny,
+ bordersize = Size.border.thin,
+ radius = Size.radius.default,
+ background = Blitbuffer.COLOR_WHITE,
+ icon_widget,
+ }
end
function CatalogWidgets.buildReadStatusBadge(book, max_width)
local icon = readStatusBadgeIcon(book)
if not icon then return nil end
- local size = math.max(Screen:scaleBySize(12), math.min(max_width, Screen:scaleBySize(20)))
- return IconWidget:new{
+ local size = math.max(Screen:scaleBySize(10), math.min(max_width, Screen:scaleBySize(16)))
+ return badgeChip(IconWidget:new{
icon = icon,
rotation_angle = readStatusBadgeRotation(icon),
width = size,
height = size,
- }
+ })
end
function CatalogWidgets.buildSelectionBadge(max_width)
- local size = math.max(Screen:scaleBySize(16), math.min(max_width, Screen:scaleBySize(26)))
- return IconWidget:new{
+ local size = math.max(Screen:scaleBySize(13), math.min(max_width, Screen:scaleBySize(22)))
+ return badgeChip(IconWidget:new{
icon = "check",
width = size,
height = size,
- }
+ })
+end
+
+-- IconWidget resolves either a stock glyph name or an absolute file path;
+-- never hand it both.
+local function iconOrFile(icon, file)
+ if file then return { file = file } end
+ return { icon = icon }
end
function CatalogWidgets.buildDownloadedBadge(max_width)
- local size = math.max(Screen:scaleBySize(14), math.min(max_width, Screen:scaleBySize(24)))
- return IconWidget:new{
- icon = "appbar.filebrowser",
- width = size,
- height = size,
- }
+ local size = math.max(Screen:scaleBySize(11), math.min(max_width, Screen:scaleBySize(18)))
+ local opts = iconOrFile("appbar.filebrowser", CatalogWidgets.assetIconFile("on_device"))
+ opts.width = size
+ opts.height = size
+ return badgeChip(IconWidget:new(opts))
end
function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected)
@@ -637,9 +685,15 @@ function CatalogWidgets.buildDetailProgressBar(percentage, width, height)
}
end
-function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state, downloaded, quiet_placeholder)
- local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, quiet_placeholder)
- local read_badge = CatalogWidgets.buildReadStatusBadge(book, math.floor(math.min(width, height) * 0.18))
+-- Badges sit over the cover art, so they are kept to a smaller share of the
+-- card than the catalog grid's: a dashboard shelf shows several covers at once
+-- and a chunky chip on each one buries the artwork.
+local DASHBOARD_BADGE_RATIO = 0.15
+
+function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state, downloaded, cover_opts)
+ local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state, cover_opts)
+ local badge_w = math.floor(math.min(width, height) * DASHBOARD_BADGE_RATIO)
+ local read_badge = CatalogWidgets.buildReadStatusBadge(book, badge_w)
if not downloaded and not read_badge then return cover end
local group = OverlapGroup:new{
@@ -648,7 +702,7 @@ function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, sta
cover,
}
if downloaded then
- local badge = CatalogWidgets.buildDownloadedBadge(math.floor(math.min(width, height) * 0.18))
+ local badge = CatalogWidgets.buildDownloadedBadge(badge_w)
badge.overlap_align = "left"
table.insert(group, badge)
end
@@ -659,36 +713,49 @@ function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, sta
return group
end
+local SECTION_HEADER_FONT_SIZE = 14
+
+-- The height of a section header's label row. Header controls are built at
+-- exactly this size, so a header carrying chevrons or a reroll button is no
+-- taller than a bare one: every section keeps the same label-to-underline
+-- distance, and the dashboard can budget one header height for all of them.
+function CatalogWidgets.dashboardSectionHeaderRowHeight()
+ local probe = TextWidget:new{
+ text = "X",
+ face = Font:getFace("cfont", SECTION_HEADER_FONT_SIZE),
+ bold = true,
+ }
+ local height = probe:getSize().h
+ probe:free()
+ return height
+end
+
-- A dashboard section header in the detail page's tab idiom: an uppercase
-- bold label sitting on a thick underline that runs out into a hairline rule.
--- An optional control (e.g. the Discover reroll button) is pinned to the
--- right edge, vertically centered on the label row.
+-- Optional controls (paging chevrons, the Discover reroll) are pinned flush to
+-- the right edge and fill the label row, so they read as part of the header
+-- rather than floating above it.
function CatalogWidgets.buildDashboardSectionHeader(text, width, right_widget)
- local face = Font:getFace("cfont", 14)
- local right
- if right_widget then
- right = HorizontalGroup:new{
- align = "center",
- right_widget,
- HorizontalSpan:new{ width = Screen:scaleBySize(6) },
- }
- end
- local right_w = right and right:getSize().w or 0
- local gap = right and (Size.span.horizontal_default + Screen:scaleBySize(4)) or 0
+ local right_w = right_widget and right_widget:getSize().w or 0
+ local gap = right_widget and Size.span.horizontal_default or 0
local label = TextWidget:new{
text = string.upper(text or ""),
- face = face,
+ face = Font:getFace("cfont", SECTION_HEADER_FONT_SIZE),
bold = true,
max_width = math.max(1, width - right_w - gap),
}
- local label_h = math.max(label:getSize().h, right and right:getSize().h or 0)
- local row_dimen = Geom:new{ w = width, h = label_h }
+ -- Controls are sized to the row, but a caller that hands over something
+ -- taller must not have it clipped.
+ local row_h = math.max(
+ CatalogWidgets.dashboardSectionHeaderRowHeight(),
+ right_widget and right_widget:getSize().h or 0)
+ local row_dimen = Geom:new{ w = width, h = row_h }
local row = OverlapGroup:new{
dimen = row_dimen:copy(),
LeftContainer:new{ dimen = row_dimen:copy(), label },
}
- if right then
- table.insert(row, RightContainer:new{ dimen = row_dimen:copy(), right })
+ if right_widget then
+ table.insert(row, RightContainer:new{ dimen = row_dimen:copy(), right_widget })
end
local underline_w = math.min(width, label:getSize().w + Screen:scaleBySize(8))
@@ -711,28 +778,111 @@ function CatalogWidgets.buildDashboardSectionHeader(text, width, right_widget)
}
end
+local STAT_VALUE_FONT_SIZE = 18
+local STAT_LABEL_FONT_SIZE = 10
+
+-- The row heights every stats block shares. Measuring once and handing the
+-- same metrics to each block is what keeps the strip on a grid: the value row
+-- and the label row land on the same two baselines whether or not a block
+-- carries a sparkline, instead of each block being centered on its own height.
+function CatalogWidgets.dashboardStatMetrics(spark_h)
+ local value_probe = TextWidget:new{
+ text = "0",
+ face = Font:getFace("cfont", STAT_VALUE_FONT_SIZE),
+ bold = true,
+ }
+ local label_probe = TextWidget:new{
+ text = "X",
+ face = Font:getFace("cfont", STAT_LABEL_FONT_SIZE),
+ }
+ local metrics = {
+ value_h = value_probe:getSize().h,
+ label_h = label_probe:getSize().h,
+ spark_h = spark_h or 0,
+ gap = Size.span.vertical_default,
+ }
+ value_probe:free()
+ label_probe:free()
+ metrics.total_h = metrics.value_h + metrics.gap + metrics.label_h
+ + (metrics.spark_h > 0 and (metrics.spark_h + metrics.gap) or 0)
+ return metrics
+end
+
-- One block of the dashboard stats strip: a big bold value over a compact
--- muted uppercase label, both centered in the given width.
-function CatalogWidgets.buildDashboardStat(value, label, width)
- local value_face = Font:getFace("cfont", 18)
- local label_face = Font:getFace("cfont", 10)
- return VerticalGroup:new{
+-- muted uppercase label, both centered in the given width. The sparkline row
+-- is reserved in every block, so blocks without one still align.
+function CatalogWidgets.buildDashboardStat(value, label, width, extra, metrics)
+ metrics = metrics or CatalogWidgets.dashboardStatMetrics(extra and extra:getSize().h or 0)
+ local col = VerticalGroup:new{
align = "center",
CenterContainer:new{
- dimen = Geom:new{ w = width, h = lineHeight(value_face) },
- TextWidget:new{ text = value or "", face = value_face, bold = true, max_width = width },
- },
- VerticalSpan:new{ width = Size.span.vertical_default },
- CenterContainer:new{
- dimen = Geom:new{ w = width, h = lineHeight(label_face) },
+ dimen = Geom:new{ w = width, h = metrics.value_h },
TextWidget:new{
- text = string.upper(label or ""),
- face = label_face,
- fgcolor = Blitbuffer.COLOR_DARK_GRAY,
+ text = value or "",
+ face = Font:getFace("cfont", STAT_VALUE_FONT_SIZE),
+ bold = true,
max_width = width,
},
},
}
+ if metrics.spark_h > 0 then
+ table.insert(col, VerticalSpan:new{ width = metrics.gap })
+ if extra then
+ table.insert(col, CenterContainer:new{
+ dimen = Geom:new{ w = width, h = metrics.spark_h },
+ extra,
+ })
+ else
+ table.insert(col, VerticalSpan:new{ width = metrics.spark_h })
+ end
+ end
+ table.insert(col, VerticalSpan:new{ width = metrics.gap })
+ table.insert(col, CenterContainer:new{
+ dimen = Geom:new{ w = width, h = metrics.label_h },
+ TextWidget:new{
+ text = string.upper(label or ""),
+ face = Font:getFace("cfont", STAT_LABEL_FONT_SIZE),
+ fgcolor = Blitbuffer.COLOR_DARK_GRAY,
+ max_width = width,
+ },
+ })
+ return col
+end
+
+-- One day of the seven-day activity chart. A day with no reading contributes
+-- only its column width: the shared baseline underneath is what shows it as an
+-- empty day, so quiet days do not need marks of their own.
+function CatalogWidgets.buildDashboardWeekBar(width, height, fill_height, is_today)
+ fill_height = math.max(0, math.min(height, fill_height or 0))
+ if fill_height <= 0 then
+ return HorizontalSpan:new{ width = width }
+ end
+ return LineWidget:new{
+ background = is_today and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
+ dimen = Geom:new{ w = width, h = fill_height },
+ }
+end
+
+-- The marker a stats block without a sparkline puts in that same row, so all
+-- four blocks carry something between their value and their label instead of
+-- three of them showing a gap. Mirrors the web dashboard's widget icons.
+function CatalogWidgets.buildDashboardStatIcon(asset_name, size)
+ local file = CatalogWidgets.assetIconFile(asset_name)
+ if not file then return nil end
+ return IconWidget:new{ file = file, width = size, height = size }
+end
+
+-- The bars sit on a light rule spanning the whole week, which is what makes the
+-- row read as a chart with an axis rather than as marks floating in the strip.
+function CatalogWidgets.buildDashboardWeekChart(bars)
+ return VerticalGroup:new{
+ align = "center",
+ bars,
+ LineWidget:new{
+ background = Blitbuffer.COLOR_LIGHT_GRAY,
+ dimen = Geom:new{ w = bars:getSize().w, h = Size.line.medium },
+ },
+ }
end
-- A muted status line (Updated / offline cache / unavailable).
@@ -786,8 +936,10 @@ function DashboardCoverCard:init()
local col = VerticalGroup:new{ align = "center" }
table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = cover_h },
- CatalogWidgets.buildDashboardCoverWidget(
- book, cover_w, cover_h, path, state, downloaded, self.quiet_placeholder == true),
+ CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state, downloaded, {
+ quiet_placeholder = self.quiet_placeholder == true,
+ no_border = true,
+ }),
})
if reserve_progress then
table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default })
@@ -939,6 +1091,86 @@ function DashboardHeroCard:onHoldSelect()
return true
end
+local HIGHLIGHT_QUOTE_FONT_SIZE = 15
+local HIGHLIGHT_QUOTE_MAX_LINES = 4
+local HIGHLIGHT_ATTRIBUTION_FONT_SIZE = 11
+
+-- The quote block inside the Highlight of the day card: the highlighted text
+-- over a muted attribution line. Built separately so the layout can measure
+-- the card before committing vertical budget to it.
+function CatalogWidgets.buildDashboardHighlightContent(highlight, width)
+ highlight = highlight or {}
+ local quote_face = Font:getFace("cfont", HIGHLIGHT_QUOTE_FONT_SIZE)
+ local attribution_face = Font:getFace("cfont", HIGHLIGHT_ATTRIBUTION_FONT_SIZE)
+ local content = VerticalGroup:new{ align = "left" }
+ table.insert(content, TextBoxWidget:new{
+ text = BD.auto("“" .. shortText(highlight.text or "", 240) .. "”"),
+ width = width,
+ height = HIGHLIGHT_QUOTE_MAX_LINES * lineHeight(quote_face),
+ height_adjust = true,
+ height_overflow_show_ellipsis = true,
+ face = quote_face,
+ })
+ local attribution_parts = {}
+ if highlight.bookTitle and highlight.bookTitle ~= "" then
+ table.insert(attribution_parts, shortText(highlight.bookTitle, 60))
+ end
+ if highlight.chapterTitle and highlight.chapterTitle ~= "" then
+ table.insert(attribution_parts, shortText(highlight.chapterTitle, 40))
+ end
+ if #attribution_parts > 0 then
+ table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
+ table.insert(content, TextWidget:new{
+ text = BD.auto(table.concat(attribution_parts, " - ")),
+ face = attribution_face,
+ fgcolor = Blitbuffer.COLOR_DARK_GRAY,
+ max_width = width,
+ })
+ end
+ return content
+end
+
+function CatalogWidgets.dashboardHighlightCardHeight(highlight, card_w)
+ local pad = Size.padding.default
+ local inner_w = math.max(1, card_w - 2 * pad - 2 * CARD_BORDER)
+ local content = CatalogWidgets.buildDashboardHighlightContent(highlight, inner_w)
+ return content:getSize().h + 2 * pad + 2 * CARD_BORDER
+end
+
+-- The Highlight of the day card: a quote card in the shared dashboard chrome
+-- that taps through to the highlighted book like any other dashboard card.
+local DashboardHighlightCard = InputContainer:extend{
+ entry = nil,
+ dimen = nil,
+ menu = nil,
+}
+
+function DashboardHighlightCard:init()
+ self.ges_events = {
+ TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
+ HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
+ }
+
+ local pad = Size.padding.default
+ local inner_w = math.max(1, self.dimen.w - 2 * pad - 2 * CARD_BORDER)
+ local inner_h = math.max(1, self.dimen.h - 2 * pad - 2 * CARD_BORDER)
+ self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, LeftContainer:new{
+ dimen = Geom:new{ w = inner_w, h = inner_h },
+ CatalogWidgets.buildDashboardHighlightContent(self.entry.highlight, inner_w),
+ })
+end
+
+function DashboardHighlightCard:onTapSelect()
+ if self.entry.book_id then
+ self.menu:onMenuSelect(self.entry)
+ end
+ return true
+end
+
+function DashboardHighlightCard:onHoldSelect()
+ return self:onTapSelect()
+end
+
-- A compact related-book card for the detail page shelves: cover only, with
-- the same tap/hold behavior as normal catalog cards.
local DetailRelatedCard = InputContainer:extend{
@@ -965,7 +1197,8 @@ function DetailRelatedCard:init()
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
- CatalogWidgets.buildDashboardCoverWidget(book, inner_w, inner_h, path, state, downloaded),
+ CatalogWidgets.buildDashboardCoverWidget(
+ book, inner_w, inner_h, path, state, downloaded, { no_border = true }),
})
end
@@ -1115,14 +1348,19 @@ function DashboardBrowseRow:init()
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
}
end
- local count_w = count_widget and count_box_w or 0
+ -- Wide counts (a four-digit library) get the width they measure at instead
+ -- of being clipped to the nominal box.
+ local count_w = count_widget and math.max(count_box_w, count_widget:getSize().w + Size.padding.small) or 0
local label_w = math.max(1, self.dimen.w - icon_box_w - gap - count_w)
local left = HorizontalGroup:new{ align = "center" }
- if entry.icon then
+ if entry.icon or entry.icon_file then
+ local icon_opts = iconOrFile(entry.icon, entry.icon_file)
+ icon_opts.width = icon_size
+ icon_opts.height = icon_size
table.insert(left, CenterContainer:new{
dimen = Geom:new{ w = icon_box_w, h = inner_h },
- IconWidget:new{ icon = entry.icon, width = icon_size, height = icon_size },
+ IconWidget:new(icon_opts),
})
else
table.insert(left, HorizontalSpan:new{ width = icon_box_w })
@@ -1142,7 +1380,7 @@ function DashboardBrowseRow:init()
if count_widget then
table.insert(row, RightContainer:new{
dimen = row_dimen:copy(),
- CenterContainer:new{ dimen = Geom:new{ w = count_box_w, h = inner_h }, count_widget },
+ CenterContainer:new{ dimen = Geom:new{ w = count_w, h = inner_h }, count_widget },
})
end
@@ -1166,39 +1404,71 @@ function DashboardBrowseRow:onHoldSelect()
return true
end
--- A small tappable icon button, used for the Discover section's reroll control.
+-- A small tappable icon button, used for the section header controls (paging
+-- chevrons, the Discover reroll). The glyph can stay smaller than the tappable
+-- box via icon_size, and entry.icon_file swaps in one of the plugin's own SVG
+-- icons. A disabled button dims its glyph and swallows the tap.
+--
+-- tap_padding_v grows the tap area above and below the drawn box without
+-- making the widget itself taller, so a header control can sit inside the
+-- label row and still take a finger-sized tap from the surrounding gap.
local DashboardIconButton = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
+ icon_size = nil,
+ enabled = true,
+ callback = nil,
+ tap_padding_v = 0,
}
function DashboardIconButton:init()
+ -- Evaluated at gesture time: self.dimen only gets its screen position when
+ -- the widget is painted.
+ local function tapRange()
+ local pad = self.tap_padding_v
+ if pad <= 0 then return self.dimen end
+ return Geom:new{
+ x = self.dimen.x,
+ y = self.dimen.y - pad,
+ w = self.dimen.w,
+ h = self.dimen.h + 2 * pad,
+ }
+ end
self.ges_events = {
- TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
- HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
+ TapSelect = { GestureRange:new{ ges = "tap", range = tapRange } },
+ HoldSelect = { GestureRange:new{ ges = "hold", range = tapRange } },
}
- local icon_size = math.max(1, math.min(self.dimen.w, self.dimen.h) - Size.padding.tiny)
+ local icon_size = self.icon_size or math.max(1, math.min(self.dimen.w, self.dimen.h) - Size.padding.tiny)
+ local icon_opts = iconOrFile(self.entry and self.entry.icon, self.entry and self.entry.icon_file)
+ icon_opts.width = icon_size
+ icon_opts.height = icon_size
+ icon_opts.dim = not self.enabled
self[1] = CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
- IconWidget:new{ icon = self.entry.icon, width = icon_size, height = icon_size },
+ IconWidget:new(icon_opts),
}
end
function DashboardIconButton:onTapSelect()
- self.menu:onMenuSelect(self.entry)
+ if not self.enabled then return true end
+ if self.callback then
+ self.callback()
+ else
+ self.menu:onMenuSelect(self.entry)
+ end
return true
end
function DashboardIconButton:onHoldSelect()
- self.menu:onMenuSelect(self.entry)
- return true
+ return self:onTapSelect()
end
CatalogWidgets.MosaicItem = MosaicItem
CatalogWidgets.ListItem = ListItem
CatalogWidgets.DashboardCoverCard = DashboardCoverCard
CatalogWidgets.DashboardHeroCard = DashboardHeroCard
+CatalogWidgets.DashboardHighlightCard = DashboardHighlightCard
CatalogWidgets.DetailRelatedCard = DetailRelatedCard
CatalogWidgets.DetailTabButton = DetailTabButton
CatalogWidgets.DetailRatingStar = DetailRatingStar
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua
index b89bffd99..6cb2dc9f3 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_dashboard_sections.lua
@@ -35,8 +35,11 @@ DashboardSections.TYPES = {
"continue-reading",
"random",
"browse",
+ "highlight",
"recently-added",
"in-progress",
+ "want-to-read",
+ "up-next-in-series",
"libraries",
"authors",
"series",
@@ -44,13 +47,29 @@ DashboardSections.TYPES = {
"smart-scopes",
}
+-- Sources served by the dashboard-section endpoint rather than the catalog
+-- books endpoint; only offered when the server advertises the capability.
+DashboardSections.SECTION_ENDPOINT_CAPABILITY = "catalogDashboardSections"
+
+local SECTION_ENDPOINT_TYPES = {
+ ["want-to-read"] = true,
+ ["up-next-in-series"] = true,
+}
+
+function DashboardSections.usesSectionEndpoint(section_type)
+ return SECTION_ENDPOINT_TYPES[section_type] == true
+end
+
local LABELS = {
["stats"] = function() return _("Stats") end,
["continue-reading"] = function() return _("Continue reading") end,
["random"] = function() return _("Discover") end,
["browse"] = function() return _("Browse") end,
+ ["highlight"] = function() return _("Highlight of the day") end,
["recently-added"] = function() return _("Recently added") end,
["in-progress"] = function() return _("In progress") end,
+ ["want-to-read"] = function() return _("Want to read") end,
+ ["up-next-in-series"] = function() return _("Up next in series") end,
["libraries"] = function() return _("Libraries") end,
["authors"] = function() return _("Authors") end,
["series"] = function() return _("Series") end,
@@ -63,8 +82,11 @@ local HELP_TEXT = {
["continue-reading"] = function() return _("Books currently in progress, shown as the dashboard hero row.") end,
["random"] = function() return _("A random selection from your whole library, reshuffled with the button in the section header.") end,
["browse"] = function() return _("Dashboard shortcuts for libraries, collections, SmartScopes, authors, series, and books.") end,
+ ["highlight"] = function() return _("One highlight from your library, picked fresh every day.") end,
["recently-added"] = function() return _("The books most recently added to your library.") end,
["in-progress"] = function() return _("The catalog view for books currently in progress.") end,
+ ["want-to-read"] = function() return _("Books marked Want to read. Needs a server that supports dashboard sections.") end,
+ ["up-next-in-series"] = function() return _("The next unread book of each series you are reading. Needs a server that supports dashboard sections.") end,
["libraries"] = function() return _("The existing Libraries catalog, including its normal navigation and back button.") end,
["authors"] = function() return _("The existing Authors catalog, including its normal pagination and back button.") end,
["series"] = function() return _("The existing Series catalog, including its normal pagination and back button.") end,
@@ -90,6 +112,8 @@ local BOOK_SOURCES = {
["random"] = true,
["recently-added"] = true,
["in-progress"] = true,
+ ["want-to-read"] = true,
+ ["up-next-in-series"] = true,
["libraries"] = true,
["authors"] = true,
["series"] = true,
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
index 83f5ee2ad..6719ca027 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_main_menu.lua
@@ -19,6 +19,7 @@ local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitApi = require("bookorbit_api")
+local BookOrbitCapabilities = require("bookorbit_capabilities")
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
local BookOrbitSweep = require("bookorbit_sweep")
local DashboardSections = require("bookorbit_dashboard_sections")
@@ -346,26 +347,44 @@ function MainMenu:chooseDashboardCatalogSource(index, section, catalog, touchmen
end)
end
+-- The capability list recorded for the configured server, when one is known.
+-- Nil (still unknown) keeps every source on offer; the dashboard downgrades
+-- the capability itself when the server answers 404.
+function MainMenu:knownServerCapabilities(catalog)
+ local client = catalog and catalog.client
+ if not client and self.newClient then
+ local ok, built = pcall(function() return self:newClient() end)
+ client = ok and built or nil
+ end
+ return client and BookOrbitCapabilities.cached(client) or nil
+end
+
function MainMenu:dashboardSectionItems(index, catalog)
local items = {}
+ local known_capabilities = self:knownServerCapabilities(catalog)
for _index, section_type in ipairs(DashboardSections.TYPES) do
+ local unsupported = DashboardSections.usesSectionEndpoint(section_type)
+ and known_capabilities ~= nil
+ and known_capabilities[DashboardSections.SECTION_ENDPOINT_CAPABILITY] ~= true
local is_selector = DashboardSections.isCatalogSelector(section_type)
- table.insert(items, {
- text = DashboardSections.label(section_type),
- mandatory = is_selector and ">" or nil,
- help_text = DashboardSections.helpText(section_type),
- checked_func = is_selector and nil or function()
- return DashboardSections.at(self.settings, index).type == section_type
- end,
- keep_menu_open = is_selector,
- callback = function(touchmenu_instance)
- if is_selector then
- self:chooseDashboardCatalogSource(index, section_type, catalog, touchmenu_instance)
- else
- self:applyDashboardSection(index, { type = section_type }, catalog, touchmenu_instance)
- end
- end,
- })
+ if not unsupported then
+ table.insert(items, {
+ text = DashboardSections.label(section_type),
+ mandatory = is_selector and ">" or nil,
+ help_text = DashboardSections.helpText(section_type),
+ checked_func = is_selector and nil or function()
+ return DashboardSections.at(self.settings, index).type == section_type
+ end,
+ keep_menu_open = is_selector,
+ callback = function(touchmenu_instance)
+ if is_selector then
+ self:chooseDashboardCatalogSource(index, section_type, catalog, touchmenu_instance)
+ else
+ self:applyDashboardSection(index, { type = section_type }, catalog, touchmenu_instance)
+ end
+ end,
+ })
+ end
end
return items
end
@@ -378,6 +397,16 @@ function MainMenu:dashboardSettingsMenu(catalog)
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
+ {
+ text = _("Tap Continue reading to open the book"),
+ checked_func = function()
+ return self.settings.catalog_dashboard_tap_resumes ~= false
+ end,
+ help_text = _([[Tapping a book under Continue reading opens it directly when it is already on the device; hold for its actions. When disabled, tapping opens the book's details instead.]]),
+ callback = function()
+ self.settings.catalog_dashboard_tap_resumes = self.settings.catalog_dashboard_tap_resumes == false
+ end,
+ },
}
for index = 1, DashboardSections.SLOT_COUNT do
table.insert(items, {
diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua
index ff36117f8..fe740854a 100644
--- a/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua
+++ b/koreader-plugin/bookorbit.koplugin/bookorbit_stats_reader.lua
@@ -236,8 +236,9 @@ function Session:eventsAfter(ids, watermark, limit)
end
-- Aggregated local reading activity for the dashboard stats strip: seconds
--- read today and over the past 7 days, plus the current daily reading streak
--- (consecutive days with any reading, still counting if today has none yet).
+-- read today, over the past 7 days and per day of that week, plus the current
+-- daily reading streak (consecutive days with any reading, still counting if
+-- today has none yet).
function Session:readingSummary()
local today = os.date("*t")
today.hour, today.min, today.sec = 0, 0, 0
@@ -249,6 +250,24 @@ function Session:readingSummary()
return res and tonumber(res[1][1]) or 0
end
+ -- Per-day totals for the strip's activity bars, oldest first, today last.
+ local day_seconds = { 0, 0, 0, 0, 0, 0, 0 }
+ local week_start = day_start - 6 * 86400
+ local day_res = self:select(
+ "SELECT date(start_time, 'unixepoch', 'localtime'), COALESCE(SUM(MIN(duration, 86400)), 0)"
+ .. " FROM page_stat_data WHERE start_time >= ? GROUP BY 1;",
+ { week_start })
+ if day_res then
+ local index_by_date = {}
+ for day = 1, 7 do
+ index_by_date[os.date("%Y-%m-%d", week_start + (day - 1) * 86400)] = day
+ end
+ for i = 1, #day_res[1] do
+ local day = index_by_date[day_res[1][i]]
+ if day then day_seconds[day] = tonumber(day_res[2][i]) or 0 end
+ end
+ end
+
local streak = 0
local res = self:select(
"SELECT DISTINCT date(start_time, 'unixepoch', 'localtime') FROM page_stat_data WHERE start_time >= ?;",
@@ -271,6 +290,7 @@ function Session:readingSummary()
return {
today_seconds = sumSince(day_start),
week_seconds = sumSince(day_start - 6 * 86400),
+ day_seconds = day_seconds,
streak_days = streak,
}
end
diff --git a/koreader-plugin/spec/bookorbit_api_test.lua b/koreader-plugin/spec/bookorbit_api_test.lua
index d9a8de529..85441c4f3 100644
--- a/koreader-plugin/spec/bookorbit_api_test.lua
+++ b/koreader-plugin/spec/bookorbit_api_test.lua
@@ -65,9 +65,11 @@ package.loaded["ltn12"] = {
},
}
+local last_request_url
package.loaded["socket.http"] = {
request = function(request)
request_ran_in_subprocess = in_subprocess
+ last_request_url = request.url
if mock_http_body then
request.sink(mock_http_body)
end
@@ -157,6 +159,13 @@ assertEqual(body, nil, "HTTP error has no decoded body")
assertEqual(err, 503, "HTTP error preserves status code")
assertEqual(errbody, nil, "invalid HTTP error body is ignored")
+mock_http_body = "{\"ok\":true}"
+mock_http_code = 200
+client:catalogDashboardSection("up-next-in-series")
+assertEqual(last_request_url,
+ "https://bookorbit.example.com/api/v1/koreader/plugin/catalog/dashboard/sections/up-next-in-series",
+ "the dashboard-section endpoint is addressed by source type")
+
local wrapped = true
local subprocess_calls = 0
local subprocess_result_mode = "normal"
diff --git a/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua
index 694a564a7..4061f2030 100644
--- a/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua
+++ b/koreader-plugin/spec/bookorbit_catalog_dashboard_section_test.lua
@@ -71,6 +71,7 @@ package.loaded["ui/size"] = {
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,
}
@@ -142,6 +143,11 @@ local function newCatalog(opts)
if opts.discover_error then return nil, opts.discover_error end
return { discover = { { id = 14 } } }
end,
+ catalogDashboardSection = function(_, section_type)
+ table.insert(requests, "section:" .. tostring(section_type))
+ if opts.section_endpoint_error then return nil, opts.section_endpoint_error end
+ return { section = { type = section_type, books = { { id = 21 } } } }
+ end,
},
}
for name, fn in pairs(CatalogDashboard) do
@@ -278,6 +284,50 @@ assertEqual(partial.dashboard ~= nil, true, "one shelf failure does not discard
assertEqual(#partial.dashboard.dashboardSlots[2].books, 0, "the failed shelf degrades to an empty list")
assertEqual(partial.dashboard.dashboardSlots[3].books[1].id, 12, "later shelves continue loading")
+-- Server-composed sources go through the dashboard-section endpoint; distinct
+-- sources cost one request each and land in their slots.
+local Capabilities = require("bookorbit_capabilities")
+Capabilities.reset()
+catalog = newCatalog{
+ sections = {
+ { type = "stats" },
+ { type = "want-to-read" },
+ { type = "up-next-in-series" },
+ { type = "browse" },
+ schemaVersion = DashboardSections.SCHEMA_VERSION,
+ },
+}
+local _, server_sections = catalog:dashboardRoot()
+assertEqual(requests[2], "section:want-to-read", "Want to read fetches through the section endpoint")
+assertEqual(requests[3], "section:up-next-in-series", "Up next in series fetches through the section endpoint")
+assertEqual(server_sections.dashboard.dashboardSlots[2].books[1].id, 21, "the section endpoint's books land in the slot")
+
+-- A 404 from the section endpoint downgrades the capability: the shelf
+-- degrades to empty, and later fetches skip the endpoint entirely.
+Capabilities.reset()
+catalog = newCatalog{
+ sections = {
+ { type = "stats" },
+ { type = "want-to-read" },
+ { type = "random" },
+ { type = "browse" },
+ schemaVersion = DashboardSections.SCHEMA_VERSION,
+ },
+ section_endpoint_error = 404,
+}
+local _, downgraded = catalog:dashboardRoot()
+assertEqual(#downgraded.dashboard.dashboardSlots[2].books, 0, "an unsupported section degrades to an empty shelf")
+assertEqual(Capabilities.cached(catalog.client)[DashboardSections.SECTION_ENDPOINT_CAPABILITY], false,
+ "a confirmed 404 records the missing capability")
+requests = {}
+catalog:dashboardRoot()
+local section_requests = 0
+for _, request in ipairs(requests) do
+ if request:find("^section:") then section_requests = section_requests + 1 end
+end
+assertEqual(section_requests, 0, "a known-missing capability skips the section endpoint")
+Capabilities.reset()
+
-- A cached body fetched for another four-slot configuration is still shown, but
-- its grid shelves are marked pending until the refresh lands.
catalog = newCatalog{
diff --git a/koreader-plugin/spec/bookorbit_catalog_dashboard_startup_test.lua b/koreader-plugin/spec/bookorbit_catalog_dashboard_startup_test.lua
index 7910ccb7f..d27e847f0 100644
--- a/koreader-plugin/spec/bookorbit_catalog_dashboard_startup_test.lua
+++ b/koreader-plugin/spec/bookorbit_catalog_dashboard_startup_test.lua
@@ -68,6 +68,7 @@ package.loaded["ui/size"] = {
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,
}
diff --git a/koreader-plugin/spec/bookorbit_catalog_streak_test.lua b/koreader-plugin/spec/bookorbit_catalog_streak_test.lua
index 024e6e110..ea2b7e059 100644
--- a/koreader-plugin/spec/bookorbit_catalog_streak_test.lua
+++ b/koreader-plugin/spec/bookorbit_catalog_streak_test.lua
@@ -43,6 +43,21 @@ assertEqual(
assertEqual(CatalogUtil.readingStreakDays({}, 14), 14, "missing account streak falls back to local streak")
assertEqual(CatalogUtil.readingStreakDays(nil, nil), 0, "missing streak data falls back to zero")
+-- Relative labels pick their bucket from the delta; the stubbed template
+-- returns the raw format string, which is enough to prove branch selection.
+local now = os.time()
+local function iso(timestamp)
+ return os.date("!%Y-%m-%dT%H:%M:%S", timestamp) .. ".000Z"
+end
+assertEqual(CatalogUtil.formatRelativeTime(iso(now - 30), now), "just now", "a fresh timestamp reads as just now")
+assertEqual(CatalogUtil.formatRelativeTime(iso(now - 300), now), "%1 min ago", "minutes bucket")
+assertEqual(CatalogUtil.formatRelativeTime(iso(now - 7200), now), "%1 h ago", "hours bucket")
+assertEqual(CatalogUtil.formatRelativeTime(iso(now - math.floor(1.5 * 86400)), now), "yesterday", "one day reads as yesterday")
+assertEqual(CatalogUtil.formatRelativeTime(iso(now - 5 * 86400), now), "%1 days ago", "days bucket")
+assertEqual(CatalogUtil.formatRelativeTime(iso(now + 3600), now), "just now", "a future timestamp clamps to just now")
+assertEqual(CatalogUtil.formatRelativeTime("not a timestamp", now), nil, "junk yields no label")
+assertEqual(CatalogUtil.formatRelativeTime(nil, now), nil, "nil yields no label")
+
local function widgetClass()
return {
new = function(_, opts)
@@ -90,6 +105,7 @@ package.loaded["ui/size"] = {
},
}
package.loaded["ui/widget/textboxwidget"] = widgetClass()
+package.loaded["ui/widget/textwidget"] = widgetClass()
package.loaded["ui/uimanager"] = {}
package.loaded["ui/widget/verticalgroup"] = widgetClass()
package.loaded["ui/widget/verticalspan"] = widgetClass()
@@ -97,19 +113,37 @@ package.loaded["bookorbit_stats_reader"] = {}
local rendered_stats = {}
package.loaded["bookorbit_catalog_widgets"] = {
- buildDashboardStat = function(value, label, width)
- table.insert(rendered_stats, { value = value, label = label, width = width })
+ buildDashboardStat = function(value, label, width, extra, metrics)
+ table.insert(rendered_stats,
+ { value = value, label = label, width = width, extra = extra, metrics = metrics })
return widgetClass():new()
end,
+ dashboardStatMetrics = function(spark_h)
+ return { value_h = 20, label_h = 10, spark_h = spark_h or 0, gap = 4, total_h = 34 }
+ end,
+ buildDashboardWeekBar = function()
+ return widgetClass():new()
+ end,
+ buildDashboardWeekChart = function(bars)
+ return bars
+ end,
+ buildDashboardStatIcon = function(asset_name)
+ return widgetClass():new{ asset_name = asset_name }
+ end,
}
local CatalogDashboard = require("bookorbit_catalog_dashboard")
-CatalogDashboard.buildDashboardStatsStrip({
- content_w = 500,
- onDeviceCount = function()
- return 2
- end,
-}, {
+
+local function newStripCatalog()
+ local catalog = { content_w = 500 }
+ for name, fn in pairs(CatalogDashboard) do
+ if name ~= "install" then catalog[name] = fn end
+ end
+ return catalog
+end
+
+local catalog = newStripCatalog()
+catalog:buildDashboardStatsStrip({
today_seconds = 600,
week_seconds = 3600,
streak_days = 14,
@@ -118,7 +152,56 @@ CatalogDashboard.buildDashboardStatsStrip({
readingStreak = { currentStreak = 20 },
})
+assertEqual(#rendered_stats, 3, "without a goal the strip keeps to the three activity blocks")
+assertEqual(rendered_stats[1].label, "Today", "dashboard renders a today stat")
+assertEqual(rendered_stats[1].value, "%1 min", "an empty day renders as minutes, not prose")
+-- No per-day data means no chart, but the block still takes the fallback icon
+-- rather than becoming the one empty slot in the strip.
+assertEqual(rendered_stats[2].extra.asset_name, "stat_week",
+ "without per-day data the week block falls back to its icon")
assertEqual(rendered_stats[3].label, "Day streak", "dashboard renders a day streak stat")
assertEqual(rendered_stats[3].value, "20", "dashboard renders the account streak instead of the local streak")
+-- With per-day activity and a yearly goal the strip gains the bars and a
+-- fourth block; inventory counts stay out of it.
+rendered_stats = {}
+catalog = newStripCatalog()
+catalog:buildDashboardStatsStrip({
+ today_seconds = 600,
+ week_seconds = 3600,
+ day_seconds = { 0, 600, 0, 1200, 0, 900, 600 },
+ streak_days = 14,
+}, {
+ totalBooks = 99,
+ readingStreak = { currentStreak = 20 },
+ readingGoal = { goalBooks = 24, completedBooks = 12, year = 2026 },
+})
+
+assertEqual(#rendered_stats, 4, "a yearly goal adds a fourth block")
+assertEqual(rendered_stats[2].extra ~= nil, true, "per-day activity renders the bars")
+assertEqual(rendered_stats[4].value, "12 / 24", "the goal block shows progress toward the yearly goal")
+
+-- Every block is laid out against one set of row heights, which is what keeps
+-- the values on a single baseline and the labels on another even though only
+-- the "Past 7 days" block carries a sparkline.
+for index, block in ipairs(rendered_stats) do
+ assertEqual(block.metrics, rendered_stats[1].metrics,
+ "block " .. index .. " shares the strip's row metrics")
+end
+assertEqual(rendered_stats[1].metrics.spark_h > 0, true,
+ "blocks without a sparkline still reserve its row")
+
+-- Every block puts something in that row: the chart for "Past 7 days", an icon
+-- for the rest, so three of the four do not render as an empty gap.
+for index, block in ipairs(rendered_stats) do
+ assertEqual(block.extra ~= nil, true, "block " .. index .. " carries a marker")
+end
+assertEqual(rendered_stats[1].extra.asset_name, "stat_today", "the today block takes the clock")
+assertEqual(rendered_stats[3].extra.asset_name, "stat_streak", "the streak block takes the flame")
+assertEqual(rendered_stats[4].extra.asset_name, "stat_goal", "the goal block takes the target")
+for _, block in ipairs(rendered_stats) do
+ assertEqual(block.label == "Library" or block.label == "On device", false,
+ "inventory counts no longer render in the strip")
+end
+
print("bookorbit_catalog_streak_test.lua: ok")
diff --git a/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua b/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua
index e87f76901..5be4391ac 100644
--- a/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua
+++ b/koreader-plugin/spec/bookorbit_dashboard_layout_test.lua
@@ -42,6 +42,7 @@ package.loaded["ui/size"] = {
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()
@@ -52,15 +53,27 @@ package.loaded["bookorbit_stats_reader"] = {
}
local HEADER_HEIGHT = 20
+local HEADER_ROW_HEIGHT = 14
+local STATUS_LINE_HEIGHT = 16
+local HIGHLIGHT_CARD_HEIGHT = 64
package.loaded["bookorbit_catalog_widgets"] = {
DashboardCoverCard = widgetClass(),
DashboardHeroCard = widgetClass(),
+ DashboardHighlightCard = widgetClass(),
DashboardBrowseRow = widgetClass(),
DashboardIconButton = widgetClass(),
+ assetIconFile = function() return nil end,
+ dashboardSectionHeaderRowHeight = function() return HEADER_ROW_HEIGHT end,
buildDashboardSectionHeader = function()
return { getSize = function() return { w = 100, h = HEADER_HEIGHT } end }
end,
+ buildStatusLabel = function()
+ return { getSize = function() return { w = 100, h = STATUS_LINE_HEIGHT } end }
+ end,
+ dashboardHighlightCardHeight = function()
+ return HIGHLIGHT_CARD_HEIGHT
+ end,
coverCardHeight = function(card_w)
return card_w + 20
end,
@@ -185,4 +198,66 @@ catalog:updateDashboardItems()
assertEqual(#catalog.shelves, 0, "a pending shelf renders no cover grid")
assertEqual(catalog.headers[2], "Discover", "a pending shelf still shows its header")
+-- A stale (cached) dashboard announces itself with a status line that takes
+-- part in the vertical budget.
+catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
+catalog.current_context.stale = true
+catalog:updateDashboardItems()
+assertEqual(catalog.dash_used <= 800, true, "the status line stays inside the available height")
+local without_status = newCatalog{ sections = DEFAULTS, avail = 800 }
+without_status:updateDashboardItems()
+assertEqual(catalog.dash_used, without_status.dash_used, "budget accounting is unchanged aside from the trailing spacer")
+
+-- The Highlight of the day slot renders its measured card and drops after
+-- stats but before any shelf when the page gets tight.
+local WITH_HIGHLIGHT = {
+ { type = "stats" }, { type = "continue-reading" }, { type = "highlight" }, { type = "random" },
+}
+catalog = newCatalog{ sections = WITH_HIGHLIGHT, avail = 800 }
+catalog.current_context.dashboard.highlightOfTheDay = { text = "quote", bookId = 7 }
+catalog:updateDashboardItems()
+assertEqual(catalog.headers[2], "Highlight of the day", "the highlight slot renders its header")
+assertEqual(#catalog.shelves, 1, "the shelf renders alongside the highlight card")
+assertEqual(catalog.dash_used <= 800, true, "the highlight card stays inside the available height")
+
+catalog = newCatalog{ sections = WITH_HIGHLIGHT, avail = 340 }
+catalog.current_context.dashboard.highlightOfTheDay = { text = "quote", bookId = 7 }
+catalog:updateDashboardItems()
+local saw_highlight = false
+for _, header in ipairs(catalog.headers) do
+ if header == "Highlight of the day" then saw_highlight = true end
+end
+assertEqual(saw_highlight, false, "a tight page drops the highlight card after stats, before shelves")
+assertEqual(#catalog.shelves, 1, "the shelf survives the tight page")
+
+-- Header controls are built at the header's label-row height, so a section
+-- carrying chevrons or a reroll button is exactly as tall as a bare one. That
+-- is what keeps the single measured header height the budget uses honest, and
+-- what stops the controls from floating above the label.
+catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
+local prev_control, next_control = catalog:buildDashboardHeaderNav("section3", 2, 4)
+local reroll_control = catalog:buildDashboardRerollButton(3)
+for label, control in pairs({ prev = prev_control, next = next_control, reroll = reroll_control }) do
+ assertEqual(control.dimen.h, HEADER_ROW_HEIGHT, label .. " control fills the header row exactly")
+ assertEqual(control.dimen.w, HEADER_ROW_HEIGHT, label .. " control is square")
+ assertEqual(control.tap_padding_v > 0, true, label .. " control takes taps from the gap around it")
+end
+assertEqual(prev_control.enabled, true, "a page-2 back chevron is live")
+assertEqual(catalog:buildDashboardHeaderNav("section3", 1, 4).enabled, false,
+ "the back chevron is disabled on the first page")
+
+-- Page turns record each section's band and narrow the refresh to it.
+catalog = newCatalog{ sections = DEFAULTS, avail = 800 }
+catalog.dimen = { x = 0, y = 0, w = 520, h = 900 }
+function catalog:menuChromeHeight() return 30, 40 end
+catalog.dash_refresh_section = "section3"
+catalog:updateDashboardItems()
+local band = catalog.dash_section_bands["section3"]
+assertEqual(band ~= nil and band.h > 0, true, "the shelf records its rendered band")
+local region = catalog.custom_refresh_region
+assertEqual(region ~= nil, true, "a page turn narrows the refresh to the changed band")
+assertEqual(region.y, 30 + band.y - 4, "the refresh band sits below the title bar with its padding")
+assertEqual(region.h, band.h + 8, "the refresh band covers the section plus padding")
+assertEqual(catalog.dash_refresh_section, nil, "the page-turn hint is consumed by the rebuild")
+
print("bookorbit_dashboard_layout_test.lua: ok")
diff --git a/koreader-plugin/spec/bookorbit_dashboard_sections_test.lua b/koreader-plugin/spec/bookorbit_dashboard_sections_test.lua
index 639cc0a68..be30f0311 100644
--- a/koreader-plugin/spec/bookorbit_dashboard_sections_test.lua
+++ b/koreader-plugin/spec/bookorbit_dashboard_sections_test.lua
@@ -30,9 +30,21 @@ local author_source = {
assertEqual(DashboardSections.normalizeEntry(nil).type, "random", "a missing entry degrades to Discover")
assertEqual(DashboardSections.normalizeEntry({ type = "not-a-source" }).type, "random", "an unknown type degrades to Discover")
-assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "random", "an obsolete pseudo-source degrades to Discover")
+assertEqual(DashboardSections.normalizeEntry({ type = "smart-scope" }).type, "random", "an obsolete pseudo-source degrades to Discover")
assertEqual(DashboardSections.normalizeEntry({ type = "authors" }).type, "random", "an unselected catalog type degrades to Discover")
+-- Server-composed sources and the highlight card are first-class slot types.
+assertEqual(DashboardSections.normalizeEntry({ type = "want-to-read" }).type, "want-to-read", "Want to read is a supported source again")
+assertEqual(DashboardSections.normalizeEntry({ type = "up-next-in-series" }).type, "up-next-in-series", "Up next in series is a supported source")
+assertEqual(DashboardSections.normalizeEntry({ type = "highlight" }).type, "highlight", "the highlight card is a supported slot")
+assertEqual(DashboardSections.isBookSource("want-to-read"), true, "Want to read renders as a shelf")
+assertEqual(DashboardSections.isBookSource("up-next-in-series"), true, "Up next in series renders as a shelf")
+assertEqual(DashboardSections.isBookSource("highlight"), false, "the highlight card has its own renderer")
+assertEqual(DashboardSections.usesSectionEndpoint("want-to-read"), true, "Want to read is served by the section endpoint")
+assertEqual(DashboardSections.usesSectionEndpoint("up-next-in-series"), true, "Up next in series is served by the section endpoint")
+assertEqual(DashboardSections.usesSectionEndpoint("recently-added"), false, "catalog-books sources skip the section endpoint")
+assertEqual(DashboardSections.label("highlight"), "Highlight of the day", "the highlight slot is labelled for the picker")
+
local normalized_author = DashboardSections.normalizeEntry(author_source)
assertEqual(normalized_author.type, "authors", "a selected author source is kept")
assertEqual(normalized_author.sourceName, "Ursula K. Le Guin", "a selected author keeps its display name")
@@ -72,7 +84,7 @@ local migrated_two = DashboardSections.normalize({
author_source,
schemaVersion = DashboardSections.LEGACY_SCHEMA_VERSION,
})
-assertEqual(migrated_two[3].type, "random", "an obsolete old row migrates safely to Discover")
+assertEqual(migrated_two[3].type, "want-to-read", "a legacy Want to read row migrates to the supported source")
assertEqual(migrated_two[4].type, "authors", "a selected catalog source survives migration")
local current = DashboardSections.normalize({
diff --git a/koreader-plugin/spec/bookorbit_stats_reader_test.lua b/koreader-plugin/spec/bookorbit_stats_reader_test.lua
index 9cadcd95f..7a6465b77 100644
--- a/koreader-plugin/spec/bookorbit_stats_reader_test.lua
+++ b/koreader-plugin/spec/bookorbit_stats_reader_test.lua
@@ -69,6 +69,9 @@ do
local today = os.date("%Y-%m-%d")
local yesterday = os.date("%Y-%m-%d", os.time() - 86400)
local Reader = load(function(sql)
+ if sql:find("GROUP BY 1", 1, true) then
+ return FakeSqlite.resultSet{ { yesterday, "600" }, { today, "1800" } }
+ end
if sql:find("COALESCE(SUM(", 1, true) then
return FakeSqlite.resultSet{ { "1800" } }
end
@@ -82,6 +85,9 @@ do
assertEqual(summary.today_seconds, 1800, "today's reading time is summed")
assertEqual(summary.week_seconds, 1800, "the week window is summed too")
assertEqual(summary.streak_days, 2, "consecutive reading days build the streak")
+ assertEqual(summary.day_seconds[7], 1800, "today lands in the last per-day bucket")
+ assertEqual(summary.day_seconds[6], 600, "yesterday lands in the bucket before it")
+ assertEqual(summary.day_seconds[1], 0, "days without reading stay zero")
end
-- A busy database gets one reopen before the query is given up on.
diff --git a/packages/types/src/koreader.ts b/packages/types/src/koreader.ts
index ec0f6814a..9652539c9 100644
--- a/packages/types/src/koreader.ts
+++ b/packages/types/src/koreader.ts
@@ -193,6 +193,8 @@ export interface KoreaderCatalogBookListItem {
seriesName: string | null;
seriesIndex: number | null;
progressPercentage: number | null;
+ /** When reading progress was last recorded, so the plugin can show recency. */
+ lastReadAt: string | null;
readStatus: string | null;
formats: string[];
hasCover: boolean;
diff --git a/server/src/modules/koreader/koreader-catalog.service.test.ts b/server/src/modules/koreader/koreader-catalog.service.test.ts
index f4e554d9d..b2f46b250 100644
--- a/server/src/modules/koreader/koreader-catalog.service.test.ts
+++ b/server/src/modules/koreader/koreader-catalog.service.test.ts
@@ -596,6 +596,7 @@ describe('KoreaderCatalogService', () => {
id: 10,
title: 'Dune',
progressPercentage: 47.4,
+ lastReadAt: '2026-03-01T00:00:00.000Z',
readStatus: 'reading',
formats: ['epub', 'pdf'],
thumbnailUrl: '/api/v1/koreader/plugin/catalog/books/10/thumbnail',
diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts
index 5fcaea73f..59983f5d2 100644
--- a/server/src/modules/koreader/koreader-catalog.service.ts
+++ b/server/src/modules/koreader/koreader-catalog.service.ts
@@ -652,6 +652,7 @@ export class KoreaderCatalogService {
seriesName: entry.seriesName,
seriesIndex: entry.seriesIndex,
progressPercentage: progress?.percentage ?? null,
+ lastReadAt: progress?.updatedAt ?? null,
readStatus,
formats,
hasCover: entry.hasCover,
@@ -713,6 +714,7 @@ export class KoreaderCatalogService {
seriesName: detail.seriesName,
seriesIndex: detail.seriesIndex,
progressPercentage: progress?.percentage ?? null,
+ lastReadAt: progress?.updatedAt ?? null,
readStatus: detail.readStatus?.status ?? null,
formats: this.uniqueFormats(files.map((file) => file.format)),
hasCover: detail.coverSource !== null,