feat(koreader): add catalog detail info rows and series navigation

Format and render series, publisher, publication date, language, ISBN, page count, and file information rows in the KOReader catalog detail view. Support tappable series navigation and format-aware download button labels with unit test coverage.
This commit is contained in:
neon
2026-07-28 04:35:08 +00:00
parent dbb10b5c2f
commit e4e20f89e3
2 changed files with 450 additions and 33 deletions
@@ -23,6 +23,7 @@ local Geom = require("ui/geometry")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local InfoMessage = require("ui/widget/infomessage")
local KeyValuePage = require("ui/widget/keyvaluepage")
local LineWidget = require("ui/widget/linewidget")
local NetworkMgr = require("ui/network/manager")
local Screen = require("device").screen
@@ -199,8 +200,174 @@ function DetailPillWidget:onTapSelect()
return true
end
-- A plain text line that also answers taps. Used for the hero series line and
-- the meta line, neither of which gets button chrome: the text is the target.
local DetailTapLineWidget = InputContainer:extend{
text = nil,
width = nil,
face = nil,
callback = nil,
}
function DetailTapLineWidget:init()
local box = TextBoxWidget:new{
text = self.text,
width = self.width,
height = lineHeight(self.face),
height_overflow_show_ellipsis = true,
face = self.face,
}
local dimen = Geom:new{ w = self.width, h = box:getSize().h }
self.dimen = dimen
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = dimen } },
}
self[1] = box
end
function DetailTapLineWidget:onTapSelect()
if self.callback then
self.callback()
end
return true
end
local CatalogDetail = {}
-- A trailing ".0" is noise on a series index: #3, not #3.0.
function CatalogDetail.formatSeriesIndex(index)
local value = tonumber(index)
if not value then return nil end
if value == math.floor(value) then return tostring(math.floor(value)) end
local text = string.format("%.2f", value):gsub("0+$", ""):gsub("%.$", "")
return text
end
-- Dates arrive as ISO-like strings but are display-only, so an unexpected
-- format falls back to the raw text instead of disappearing.
local function datePart(value)
if type(value) ~= "string" then return nil end
local date = value:match("^(%d%d%d%d%-%d%d%-%d%d)")
if date then return date end
return cleanInlineText(value)
end
function CatalogDetail.detailSeriesParams(detail)
if not detail then return nil end
if detail.seriesId then
return { seriesId = tonumber(detail.seriesId), sort = "series" }
end
local name = cleanInlineText(detail.seriesName)
if not name then return nil end
return { series = name, sort = "series" }
end
function CatalogDetail.detailSeriesText(detail)
local name = detail and cleanInlineText(detail.seriesName)
if not name then return nil end
local index = CatalogDetail.formatSeriesIndex(detail.seriesIndex)
if not index then return name end
return T(_("%1 #%2"), name, index)
end
-- The tappable hero line. The trailing chevron is the whole affordance.
function CatalogDetail.detailSeriesLine(detail)
local text = CatalogDetail.detailSeriesText(detail)
if not text then return nil end
return text .. " \u{203A}"
end
local function fileInfoLabel(file)
local label = string.upper(file.format or "file")
local extras = {}
local size = formatBytes(file.sizeBytes)
if size ~= "" then table.insert(extras, size) end
local duration = formatDuration(file.durationSeconds)
if duration then table.insert(extras, duration) end
if #extras == 0 then return label end
return label .. " - " .. table.concat(extras, ", ")
end
--[[--
The long-tail metadata the detail page deliberately keeps off the hero, as
key/value rows for the Book info sheet. Pure: it reads the detail table it is
given and never fetches, so the offline cache renders the same rows.
Genres, tags, rating and status are excluded on purpose; they are already on
the page or reachable from the action sheet.
]]
function CatalogDetail.bookInfoRows(detail)
detail = detail or {}
local rows = {}
local function add(key, value, extra)
value = cleanInlineText(value)
if not value then return end
local row = { key = key, value = value }
for name, item in pairs(extra or {}) do row[name] = item end
table.insert(rows, row)
end
add(_("Series"), CatalogDetail.detailSeriesText(detail), {
series_params = CatalogDetail.detailSeriesParams(detail),
series_title = cleanInlineText(detail.seriesName),
})
add(_("Publisher"), detail.publisher)
add(_("Published"), datePart(detail.publishedDate)
or (detail.publishedYear and tostring(detail.publishedYear) or nil))
add(_("Language"), detail.language)
add(_("ISBN-13"), detail.isbn13)
add(_("ISBN-10"), detail.isbn10)
add(_("Pages"), detail.pageCount and tostring(detail.pageCount) or nil)
add(_("Library"), detail.libraryName)
local collections = {}
for _, collection in ipairs(detail.collections or {}) do
local name = cleanInlineText(collection.name)
if name then table.insert(collections, name) end
end
if #collections > 0 then
add(_("Collections"), table.concat(collections, ", "))
end
-- Hoisted: the loop variable would shadow gettext inside the body.
local file_key = _("File")
for _, file in ipairs(detail.files or {}) do
add(file_key, fileInfoLabel(file))
end
add(_("Added"), datePart(detail.addedAt))
return rows
end
function CatalogDetail:showBookInfo(detail)
local rows = CatalogDetail.bookInfoRows(detail)
if #rows == 0 then
UIManager:show(InfoMessage:new{ text = _("No further book information."), timeout = 2 })
return
end
local page
local kv_pairs = {}
for _, row in ipairs(rows) do
local entry = { row.key, row.value }
if row.series_params then
entry.callback = function()
UIManager:close(page)
self:loadBooks(row.series_params, row.series_title or _("Series"), true)
end
end
table.insert(kv_pairs, entry)
end
page = KeyValuePage:new{ title = _("Book info"), kv_pairs = kv_pairs }
UIManager:show(page)
end
function CatalogDetail:openDetailSeries(detail)
local params = CatalogDetail.detailSeriesParams(detail)
if not params then return end
self:loadBooks(params, cleanInlineText(detail.seriesName) or _("Series"), true)
end
function CatalogDetail:detailReadPath(detail)
local fallback
for _, file in ipairs(self:supportedFiles(detail)) do
@@ -429,6 +596,14 @@ function CatalogDetail:showBookActionSheet(detail, opts)
end
if include_details then
addRow({
text = _("Book info"),
callback = function()
closeThen(function()
self:showBookInfo(detail)
end)
end,
})
addRow({
text = _("Genres"),
enabled = has_genres,
@@ -665,6 +840,12 @@ function CatalogDetail:downloadButtonLabel(supported_files)
local file = self:nextDownloadFile(supported_files)
local format = cleanInlineText(file and file.format)
if not format then return _("Download") end
-- Size is what decides whether a download is worth starting over a slow
-- connection, so it rides the button when the server knows it.
local size = formatBytes(file and file.sizeBytes)
if size ~= "" then
return T(_("Download %1 - %2"), string.upper(format), size)
end
return T(_("Download (%1)"), string.upper(format))
end
@@ -782,11 +963,12 @@ function CatalogDetail:recalculateDetailDimen()
}
end
-- Page count is deliberately absent: the progress line right below already
-- carries it, and the Book info sheet lists it again.
function CatalogDetail.detailHeroMetaLine(_unused, detail)
local parts = {}
if detail.publishedYear then table.insert(parts, tostring(detail.publishedYear)) end
if detail.publisher then table.insert(parts, detail.publisher) end
if detail.pageCount then table.insert(parts, T(_("%1 pages"), detail.pageCount)) end
return #parts > 0 and table.concat(parts, " - ") or nil
end
@@ -1088,45 +1270,68 @@ function CatalogDetail:buildDetailHeader(detail, width)
self.detail_read_button = nil
self.detail_download_button = nil
self.detail_more_pills_button = nil
self.detail_series_line = nil
self.detail_meta_line = nil
local title_face = Font:getFace("cfont", 21)
local top = VerticalGroup:new{ align = "left" }
table.insert(top, TextBoxWidget:new{
text = BD.auto(detail.title or _("Untitled")),
width = text_w,
height = 2 * lineHeight(title_face),
height_adjust = true,
height_overflow_show_ellipsis = true,
bold = true,
face = title_face,
})
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_XS })
local author_face = Font:getFace("smallinfofont", 16)
table.insert(top, TextBoxWidget:new{
text = BD.auto(joinNames(detail.authors) or _("Unknown author")),
width = text_w,
height = lineHeight(author_face),
height_overflow_show_ellipsis = true,
face = author_face,
})
local meta_face = Font:getFace("x_smallinfofont", 13)
local series_line = self:detailSeriesLine(detail)
local meta_line = self:detailHeroMetaLine(detail)
if meta_line then
local meta_face = Font:getFace("x_smallinfofont", 13)
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_XS })
table.insert(top, TextBoxWidget:new{
text = meta_line,
local function buildTop(with_series)
self.detail_series_line = nil
self.detail_meta_line = nil
local group = VerticalGroup:new{ align = "left" }
table.insert(group, TextBoxWidget:new{
text = BD.auto(detail.title or _("Untitled")),
width = text_w,
height = lineHeight(meta_face),
height = 2 * lineHeight(title_face),
height_adjust = true,
height_overflow_show_ellipsis = true,
face = meta_face,
bold = true,
face = title_face,
})
end
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_S })
table.insert(top, self:buildDetailRating(detail, text_w))
local pills = self:buildDetailPills(detail, text_w)
if pills then
table.insert(top, VerticalSpan:new{ width = DETAIL_GAP_S })
table.insert(top, pills)
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
table.insert(group, TextBoxWidget:new{
text = BD.auto(joinNames(detail.authors) or _("Unknown author")),
width = text_w,
height = lineHeight(author_face),
height_overflow_show_ellipsis = true,
face = author_face,
})
if with_series and series_line then
self.detail_series_line = DetailTapLineWidget:new{
text = BD.auto(series_line),
width = text_w,
face = author_face,
callback = function()
self:openDetailSeries(detail)
end,
}
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
table.insert(group, self.detail_series_line)
end
if meta_line then
self.detail_meta_line = DetailTapLineWidget:new{
text = meta_line,
width = text_w,
face = meta_face,
callback = function()
self:showBookInfo(detail)
end,
}
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_XS })
table.insert(group, self.detail_meta_line)
end
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_S })
table.insert(group, self:buildDetailRating(detail, text_w))
local pills = self:buildDetailPills(detail, text_w)
if pills then
table.insert(group, VerticalSpan:new{ width = DETAIL_GAP_S })
table.insert(group, pills)
end
return group
end
local bottom = VerticalGroup:new{ align = "left" }
@@ -1134,6 +1339,14 @@ function CatalogDetail:buildDetailHeader(detail, width)
table.insert(bottom, VerticalSpan:new{ width = DETAIL_GAP_M })
table.insert(bottom, self:buildDetailButtons(detail, text_w))
-- The right column is pinned to the cover's top and bottom edges, so the
-- optional series line is the first thing to drop when the info block would
-- otherwise run into the action block on a small screen.
local top = buildTop(true)
if series_line and top:getSize().h + bottom:getSize().h > cover_h then
top = buildTop(false)
end
local flex = math.max(DETAIL_GAP_M, cover_h - top:getSize().h - bottom:getSize().h)
local right = VerticalGroup:new{ align = "left" }
table.insert(right, top)
@@ -1378,6 +1591,8 @@ function CatalogDetail:updateDetailItems(select_number, no_recalculate_dimen)
local used = DETAIL_GAP_S + header:getSize().h
local focus_row = {}
if self.detail_series_line then table.insert(focus_row, self.detail_series_line) end
if self.detail_meta_line then table.insert(focus_row, self.detail_meta_line) end
for _, star in ipairs(self.detail_rating_stars or {}) do
table.insert(focus_row, star)
end
@@ -0,0 +1,202 @@
-- The Book info sheet is built from a pure helper so the long-tail metadata
-- can be asserted without a screen: null skipping, series index trimming, size
-- and date formatting, and the download button's size suffix.
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
local function identity(value) return value end
local function stubWidget()
local widget = {}
widget.__index = widget
function widget:new(opts)
opts = opts or {}
return setmetatable(opts, widget)
end
function widget:extend(opts)
local child = setmetatable(opts or {}, { __index = self })
child.__index = child
child.new = widget.new
child.extend = widget.extend
return child
end
function widget:getSize() return { w = 0, h = 0 } end
return widget
end
package.loaded["ui/bidi"] = { auto = identity, dirpath = identity, filepath = identity }
package.loaded["ffi/blitbuffer"] = { COLOR_GRAY = 0 }
package.loaded["ui/widget/button"] = stubWidget()
package.loaded["ui/widget/buttondialog"] = stubWidget()
package.loaded["ui/widget/container/centercontainer"] = stubWidget()
package.loaded["ui/widget/container/leftcontainer"] = stubWidget()
package.loaded["ui/widget/container/inputcontainer"] = stubWidget()
package.loaded["ui/widget/horizontalgroup"] = stubWidget()
package.loaded["ui/widget/horizontalspan"] = stubWidget()
package.loaded["ui/widget/verticalgroup"] = stubWidget()
package.loaded["ui/widget/verticalspan"] = stubWidget()
package.loaded["ui/widget/linewidget"] = stubWidget()
package.loaded["ui/widget/textboxwidget"] = stubWidget()
package.loaded["ui/widget/textviewer"] = stubWidget()
package.loaded["ui/widget/infomessage"] = stubWidget()
package.loaded["ui/widget/keyvaluepage"] = stubWidget()
package.loaded["ui/geometry"] = stubWidget()
package.loaded["ui/gesturerange"] = stubWidget()
package.loaded["ui/font"] = { getFace = function() return { size = 12 } end }
package.loaded["ui/size"] = { line = { medium = 1 } }
package.loaded["ui/network/manager"] = { isConnected = function() return true end }
package.loaded["device"] = { screen = { scaleBySize = function(_, value) return value end } }
package.loaded["ui/uimanager"] = { show = function() end, close = function() end }
package.loaded["document/documentregistry"] = { hasProvider = function() return true end }
package.loaded["util"] = {
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") end,
fixUtf8 = function(value) return value end,
htmlToPlainTextIfHtml = identity,
}
package.loaded["ffi/util"] = {
template = function(pattern, a, b)
return (pattern:gsub("%%1", tostring(a)):gsub("%%2", tostring(b)))
end,
}
package.loaded["gettext"] = setmetatable({}, { __call = function(_, value) return value end })
package.loaded["bookorbit_catalog_widgets"] = {
buildCoverWidget = function() end,
buildDetailPill = function() end,
buildDetailProgressBar = function() end,
DetailRelatedCard = stubWidget(),
DetailTabButton = stubWidget(),
DetailRatingStar = stubWidget(),
}
local CatalogDetail = require("bookorbit_catalog_detail")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
end
end
local function rowValue(rows, key)
for _, row in ipairs(rows) do
if row.key == key then return row.value end
end
end
local function rowCount(rows, key)
local count = 0
for _, row in ipairs(rows) do
if row.key == key then count = count + 1 end
end
return count
end
-- Series index formatting
assertEqual(CatalogDetail.formatSeriesIndex(3), "3", "a whole index drops the decimal")
assertEqual(CatalogDetail.formatSeriesIndex(3.0), "3", "a trailing .0 is trimmed")
assertEqual(CatalogDetail.formatSeriesIndex(1.5), "1.5", "a half index keeps one decimal")
assertEqual(CatalogDetail.formatSeriesIndex(1.25), "1.25", "two decimals survive")
assertEqual(CatalogDetail.formatSeriesIndex(nil), nil, "no index formats to nothing")
assertEqual(CatalogDetail.formatSeriesIndex("nonsense"), nil, "an unparsable index formats to nothing")
assertEqual(CatalogDetail.detailSeriesText({ seriesName = "Discworld", seriesIndex = 3.0 }), "Discworld #3",
"the series text pairs name and index")
assertEqual(CatalogDetail.detailSeriesText({ seriesName = "Discworld" }), "Discworld",
"a series with no index shows just the name")
assertEqual(CatalogDetail.detailSeriesText({ seriesIndex = 3 }), nil, "an index without a name is not a series line")
assertEqual(CatalogDetail.detailSeriesLine({ seriesName = "Discworld", seriesIndex = 3 }), "Discworld #3 \u{203A}",
"the hero line carries the tap chevron")
assertEqual(CatalogDetail.detailSeriesLine({}), nil, "no series means no hero line")
-- Series navigation params
local by_id = CatalogDetail.detailSeriesParams({ seriesId = 12, seriesName = "Discworld" })
assertEqual(by_id.seriesId, 12, "a series id drives the books context")
assertEqual(by_id.sort, "series", "a series list sorts by series order")
local by_name = CatalogDetail.detailSeriesParams({ seriesName = "Discworld" })
assertEqual(by_name.series, "Discworld", "without an id the name drives the context")
assertEqual(by_name.seriesId, nil, "no id is invented")
assertEqual(CatalogDetail.detailSeriesParams({}), nil, "a book with no series has no context")
-- Book info rows
local full = {
seriesId = 12,
seriesName = "Discworld",
seriesIndex = 3.0,
publisher = "Gollancz",
publishedDate = "1987-06-01T00:00:00.000Z",
publishedYear = 1987,
language = "en",
isbn13 = "9780552131056",
isbn10 = "0552131059",
pageCount = 285,
libraryName = "Fiction",
collections = { { id = 1, name = "Favourites" }, { id = 2, name = "To reread" } },
files = {
{ id = 1, format = "epub", sizeBytes = 2411724 },
{ id = 2, format = "m4b", sizeBytes = nil, durationSeconds = 7200 },
},
addedAt = "2026-02-14T09:30:00.000Z",
-- Deliberately excluded: already on the page or in existing viewers.
genres = { "Fantasy" },
tags = { "funny" },
rating = 5,
readStatus = "read",
description = "A book.",
subtitle = "A Discworld Novel",
}
local rows = CatalogDetail.bookInfoRows(full)
assertEqual(rowValue(rows, "Series"), "Discworld #3", "series row carries name and trimmed index")
assertEqual(rows[1].key, "Series", "series leads the sheet")
assertEqual(rows[1].series_params.seriesId, 12, "the series row is tappable")
assertEqual(rows[1].series_title, "Discworld", "the series row names the list it opens")
assertEqual(rowValue(rows, "Publisher"), "Gollancz", "publisher is shown")
assertEqual(rowValue(rows, "Published"), "1987-06-01", "an ISO timestamp is reduced to its date")
assertEqual(rowValue(rows, "Language"), "en", "language is shown")
assertEqual(rowValue(rows, "ISBN-13"), "9780552131056", "isbn13 is shown")
assertEqual(rowValue(rows, "ISBN-10"), "0552131059", "isbn10 is shown")
assertEqual(rowValue(rows, "Pages"), "285", "page count is shown")
assertEqual(rowValue(rows, "Library"), "Fiction", "library is shown")
assertEqual(rowValue(rows, "Collections"), "Favourites, To reread", "collections are comma joined")
assertEqual(rowCount(rows, "File"), 2, "one row per file")
assertEqual(rows[10].value, "EPUB - 2.3 MB", "a file row shows format and size")
assertEqual(rows[11].value, "M4B - 2 h", "a sizeless audio file falls back to its duration")
assertEqual(rowValue(rows, "Added"), "2026-02-14", "added date is reduced to its date")
assertEqual(rowValue(rows, "Genres"), nil, "genres stay off the sheet")
assertEqual(rowValue(rows, "Tags"), nil, "tags stay off the sheet")
assertEqual(rowValue(rows, "Rating"), nil, "rating stays off the sheet")
assertEqual(rowValue(rows, "Subtitle"), nil, "subtitle stays off the page and the sheet")
-- Null skipping
local sparse = CatalogDetail.bookInfoRows({ libraryName = "Fiction" })
assertEqual(#sparse, 1, "rows without a value are skipped entirely")
assertEqual(sparse[1].key, "Library", "the one known value survives")
assertEqual(#CatalogDetail.bookInfoRows({}), 0, "a book with no metadata collapses to nothing")
assertEqual(#CatalogDetail.bookInfoRows(nil), 0, "a missing detail collapses to nothing")
-- publishedYear is the fallback when there is no full date, and an unexpected
-- date string is shown verbatim rather than dropped.
assertEqual(rowValue(CatalogDetail.bookInfoRows({ publishedYear = 1987 }), "Published"), "1987",
"the year stands in for a missing date")
assertEqual(rowValue(CatalogDetail.bookInfoRows({ publishedDate = "Spring 1987" }), "Published"), "Spring 1987",
"an unparsable date is display-only, not dropped")
-- A file with neither size nor duration still names its format.
assertEqual(rowValue(CatalogDetail.bookInfoRows({ files = { { format = "cbz" } } }), "File"), "CBZ",
"a file with no size still lists its format")
-- Download button label
local menu = { nextDownloadFile = function(_, files) return files[1] end }
setmetatable(menu, { __index = CatalogDetail })
assertEqual(menu:downloadButtonLabel({}), "No supported format", "no supported file says so")
assertEqual(menu:downloadButtonLabel({ { format = "epub", sizeBytes = 2411724 } }), "Download EPUB - 2.3 MB",
"the button carries the format and the size")
assertEqual(menu:downloadButtonLabel({ { format = "epub" } }), "Download (EPUB)",
"an unknown size leaves the original label untouched")
print("bookorbit_detail_info_rows_test.lua: ok")