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,