mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(series): add KOReader series sorting
Expose series-specific sorting controls and gate publication ordering on server capability support. Closes #2015
This commit is contained in:
@@ -1109,7 +1109,7 @@ function BookOrbitCatalog:titleForSection(section)
|
||||
end
|
||||
|
||||
function BookOrbitCatalog:paramsForEntry(section, entry)
|
||||
local params = { sort = "title" }
|
||||
local params = { sort = section == "series" and "series" or "title" }
|
||||
if section == "libraries" then
|
||||
params.libraryId = tonumber(entry.id)
|
||||
elseif section == "collections" then
|
||||
|
||||
@@ -54,11 +54,11 @@ end
|
||||
function CatalogDetail.detailSeriesParams(detail)
|
||||
if not detail then return nil end
|
||||
if detail.seriesId then
|
||||
return { seriesId = tonumber(detail.seriesId), sort = "title" }
|
||||
return { seriesId = tonumber(detail.seriesId), sort = "series" }
|
||||
end
|
||||
local name = cleanInlineText(detail.seriesName)
|
||||
if not name then return nil end
|
||||
return { series = name, sort = "title" }
|
||||
return { series = name, sort = "series" }
|
||||
end
|
||||
|
||||
function CatalogDetail.detailSeriesText(detail)
|
||||
|
||||
@@ -34,6 +34,7 @@ local cloneParams = CatalogUtil.cloneParams
|
||||
local CatalogLists = {}
|
||||
|
||||
CatalogLists.CAPABILITY = "catalogListsV2"
|
||||
CatalogLists.PUBLISHED_SORT_CAPABILITY = "catalogPublishedSort"
|
||||
local DUMP_DIR = os.getenv("BO_LIST_DUMP")
|
||||
|
||||
local SECTION_PAGED = { authors = true, series = true }
|
||||
@@ -508,8 +509,32 @@ end
|
||||
|
||||
-- ----------------------------------------------------------------- sheets
|
||||
local SORT_IDS = { "title", "author", "series", "recently_added", "recently_read" }
|
||||
local SERIES_SORTS = {
|
||||
{ id = "series", label = _("Series order") },
|
||||
{ id = "published", label = _("Publication date"), capability = CatalogLists.PUBLISHED_SORT_CAPABILITY },
|
||||
{ id = "title", label = _("Title") },
|
||||
{ id = "recently_read", label = _("Last read") },
|
||||
{ id = "recently_added", label = _("Added to library") },
|
||||
}
|
||||
local SHOW_IDS = { false, "unread", "reading", "finished" }
|
||||
|
||||
local function seriesSorts(self)
|
||||
local known = Capabilities.cached(self.client) or {}
|
||||
local sorts = {}
|
||||
for _i, sort in ipairs(SERIES_SORTS) do
|
||||
if not sort.capability or known[sort.capability] == true then table.insert(sorts, sort) end
|
||||
end
|
||||
return sorts
|
||||
end
|
||||
|
||||
local function seriesOrder(sort)
|
||||
if sort == "series" then return { _("First to last"), _("Last to first") }, "asc" end
|
||||
if sort == "published" then return { _("Oldest"), _("Newest") }, "asc" end
|
||||
if sort == "title" then return { _("A–Z"), _("Z–A") }, "asc" end
|
||||
if sort == "recently_read" then return { _("Recent first"), _("Oldest first") }, "desc" end
|
||||
return { _("Newest"), _("Oldest") }, "desc"
|
||||
end
|
||||
|
||||
function CatalogLists:listSheetState(context)
|
||||
context = context or self.current_context or {}
|
||||
local sheet = context.list_sheet
|
||||
@@ -522,15 +547,20 @@ function CatalogLists:listOpenSheet(kind, focus)
|
||||
local list_kind = self:listKind(context)
|
||||
if kind == "sort" then
|
||||
local staged = {}
|
||||
if list_kind == "books" or list_kind == "reading" then
|
||||
if list_kind == "books" or list_kind == "reading" or list_kind == "seriespage" then
|
||||
local params = context.params or {}
|
||||
local sort = params.sort or (list_kind == "seriespage" and "series" or "title")
|
||||
if list_kind == "seriespage" and sort == "published" then
|
||||
local known = Capabilities.cached(self.client) or {}
|
||||
if known[CatalogLists.PUBLISHED_SORT_CAPABILITY] ~= true then sort = "series" end
|
||||
end
|
||||
staged = {
|
||||
sort = params.sort or "title",
|
||||
order = self:effectiveOrder(params),
|
||||
sort = sort,
|
||||
order = params.order or CatalogUtil.NATURAL_ORDER[sort] or "asc",
|
||||
readStatus = params.readStatus,
|
||||
libraryId = params.libraryId,
|
||||
format = params.format,
|
||||
on_device = context.on_device_filter == true,
|
||||
libraryId = list_kind ~= "seriespage" and params.libraryId or nil,
|
||||
format = list_kind ~= "seriespage" and params.format or nil,
|
||||
on_device = list_kind ~= "seriespage" and context.on_device_filter == true,
|
||||
}
|
||||
else
|
||||
staged = { sort = context.sort or "name", filter = context.filter }
|
||||
@@ -595,6 +625,16 @@ function CatalogLists:listApplySheet()
|
||||
end)
|
||||
return
|
||||
end
|
||||
if kind == "seriespage" then
|
||||
local params = cloneParams(context.params or {})
|
||||
params.sort = s.sort
|
||||
params.order = s.order ~= (CatalogUtil.NATURAL_ORDER[s.sort] or "asc") and s.order or nil
|
||||
params.readStatus = s.readStatus or nil
|
||||
params.page = 1
|
||||
params.size = nil
|
||||
self:runConnected(function() self:fetchBookPage(params, context.title, false) end)
|
||||
return
|
||||
end
|
||||
if kind == "authors" or kind == "series" then
|
||||
self:reloadSection(context.section, { page = 1, sort = s.sort, filter = s.filter or false })
|
||||
end
|
||||
@@ -606,6 +646,8 @@ function CatalogLists:listResetSheet()
|
||||
local kind = self:listKind()
|
||||
if kind == "books" or kind == "reading" then
|
||||
sheet.staged = { sort = "title", order = "asc", on_device = false }
|
||||
elseif kind == "seriespage" then
|
||||
sheet.staged = { sort = "series", order = "asc" }
|
||||
else
|
||||
sheet.staged = { sort = "name" }
|
||||
end
|
||||
@@ -617,6 +659,25 @@ end
|
||||
function CatalogLists:listSortSheetModel(sheet)
|
||||
local s = sheet.staged
|
||||
local kind = self:listKind()
|
||||
if kind == "seriespage" then
|
||||
local sorts = seriesSorts(self)
|
||||
local labels, sort_index = {}, 1
|
||||
for i, sort in ipairs(sorts) do
|
||||
labels[i] = sort.label
|
||||
if sort.id == s.sort then sort_index = i end
|
||||
end
|
||||
local order_labels, first_order = seriesOrder(s.sort)
|
||||
local show_index = 1
|
||||
for i, id in ipairs(SHOW_IDS) do if (id or nil) == s.readStatus then show_index = i end end
|
||||
return {
|
||||
title = _("Sort and filter"),
|
||||
sorts = { labels = labels, selected = sort_index },
|
||||
order = { labels = order_labels, selected = (s.order or first_order) == first_order and 1 or 2 },
|
||||
shows = { labels = { _("All"), _("Unread"), _("Reading"), _("Finished") }, selected = show_index },
|
||||
rows = {},
|
||||
note = _("Saved for this list"),
|
||||
}
|
||||
end
|
||||
if kind == "books" or kind == "reading" then
|
||||
local sort_index = 1
|
||||
for i, id in ipairs(SORT_IDS) do if id == s.sort then sort_index = i end end
|
||||
@@ -705,6 +766,10 @@ function CatalogLists:listRun(action, region, hold)
|
||||
if lk == "books" or lk == "reading" then
|
||||
sheet.staged.sort = SORT_IDS[action.index] or "title"
|
||||
sheet.staged.order = CatalogUtil.NATURAL_ORDER[sheet.staged.sort] or "asc"
|
||||
elseif lk == "seriespage" then
|
||||
local sort = seriesSorts(self)[action.index] or seriesSorts(self)[1]
|
||||
sheet.staged.sort = sort and sort.id or "series"
|
||||
sheet.staged.order = CatalogUtil.NATURAL_ORDER[sheet.staged.sort] or "asc"
|
||||
else
|
||||
sheet.staged.sort = action.index == 2 and "books" or "name"
|
||||
end
|
||||
@@ -715,13 +780,16 @@ function CatalogLists:listRun(action, region, hold)
|
||||
local time_sort = CatalogUtil.SORT_KIND[sheet.staged.sort] == "time"
|
||||
local first = time_sort and "desc" or "asc"
|
||||
sheet.staged.order = action.index == 1 and first or (first == "asc" and "desc" or "asc")
|
||||
elseif lk == "seriespage" then
|
||||
local _labels, first = seriesOrder(sheet.staged.sort)
|
||||
sheet.staged.order = action.index == 1 and first or (first == "asc" and "desc" or "asc")
|
||||
else
|
||||
sheet.staged.sort = action.index == 2 and "name_desc" or "name"
|
||||
end
|
||||
return self:updateItems(nil, true)
|
||||
elseif kind == "sheet-show" and sheet then
|
||||
local lk = self:listKind()
|
||||
if lk == "books" or lk == "reading" then
|
||||
if lk == "books" or lk == "reading" or lk == "seriespage" then
|
||||
sheet.staged.readStatus = SHOW_IDS[action.index] or nil
|
||||
else
|
||||
sheet.staged.filter = action.index == 2 and "reading" or action.index == 3 and "finished" or nil
|
||||
@@ -1027,17 +1095,19 @@ end
|
||||
|
||||
local SORT_SHORT = {
|
||||
title = _("Title A–Z"), author = _("Author A–Z"), series = _("Series order"), recently_added = _("Recently added"),
|
||||
recently_updated = _("Recently updated"), recently_read = _("Last read"),
|
||||
published = _("Publication date"), recently_updated = _("Recently updated"), recently_read = _("Last read"),
|
||||
}
|
||||
|
||||
function CatalogLists:listSortLabel(params)
|
||||
local sort = params.sort or "title"
|
||||
local order = self:effectiveOrder(params)
|
||||
local order = params.order or CatalogUtil.NATURAL_ORDER[sort] or "asc"
|
||||
local label = SORT_SHORT[sort] or _("Title A–Z")
|
||||
if CatalogUtil.SORT_KIND[sort] == "alpha" and order == "desc" then
|
||||
label = sort == "title" and _("Title Z–A") or sort == "author" and _("Author Z–A") or label
|
||||
label = sort == "title" and _("Title Z–A") or sort == "author" and _("Author Z–A") or sort == "series" and _("Reverse series order") or label
|
||||
elseif CatalogUtil.SORT_KIND[sort] == "time" and order == "asc" then
|
||||
label = label .. " " .. _("(oldest)")
|
||||
label = sort == "published" and _("Publication date (oldest)") or label .. " " .. _("(oldest)")
|
||||
elseif sort == "published" then
|
||||
label = _("Publication date (newest)")
|
||||
end
|
||||
return label
|
||||
end
|
||||
@@ -1060,6 +1130,15 @@ function CatalogLists:listToolbar(kind, context)
|
||||
{ icon = "library", label = lib_label, on = params.libraryId ~= nil, action = { kind = "sort-sheet", focus = "library" }, key = "tb-library" },
|
||||
}
|
||||
end
|
||||
if kind == "seriespage" then
|
||||
if not self:listsSupported() then return {} end
|
||||
local show = params.readStatus
|
||||
local show_label = show == "unread" and _("Unread") or show == "reading" and _("Reading") or show == "finished" and _("Finished") or _("All books")
|
||||
return {
|
||||
{ icon = "arrow-up-down", label = self:listSortLabel(params), action = { kind = "sort-sheet", focus = "sort" }, key = "tb-sort" },
|
||||
{ icon = "list-filter", label = show_label, on = show ~= nil, action = { kind = "sort-sheet", focus = "show" }, key = "tb-show" },
|
||||
}
|
||||
end
|
||||
if not self:listsSupported() then return {} end
|
||||
local sort = context.sort or "name"
|
||||
local sort_label = sort == "books" and _("Most books") or sort == "name_desc" and _("Name Z–A") or _("Name A–Z")
|
||||
@@ -1212,7 +1291,8 @@ function CatalogLists:listBody(kind, context)
|
||||
if not next_book then for i, b in ipairs(books) do if states[i] ~= "finished" then next_book, cur = b, i; break end end end
|
||||
local finished, reading = 0, 0
|
||||
for _i, s in ipairs(states) do if s == "finished" then finished = finished + 1 elseif s == "reading" then reading = reading + 1 end end
|
||||
local layout = ListPages.seriesLayout(sw, sh, self:listView(kind), #books, context.list_offline == true)
|
||||
local toolbar = context.list_offline ~= true
|
||||
local layout = ListPages.seriesLayout(sw, sh, self:listView(kind), #books, context.list_offline == true, toolbar)
|
||||
context.local_pages = layout.pages
|
||||
context.local_page = math.min(context.local_page or 1, layout.pages)
|
||||
local lp = context.local_page
|
||||
@@ -1220,10 +1300,10 @@ function CatalogLists:listBody(kind, context)
|
||||
local last = math.min(#books, lp == 1 and layout.first or (first + layout.rest - 1))
|
||||
local m = {
|
||||
entries = books, view = self:listView(kind), screen_w = sw, screen_h = sh, local_page = context.local_page,
|
||||
offline = context.list_offline and {} or nil,
|
||||
offline = context.list_offline and {} or nil, toolbar = toolbar,
|
||||
select = self:isBulkSelectionActive(),
|
||||
selected = function(b) return self.bulk_select_all or self:bulkIsBookSelected(b) end,
|
||||
order_label = (context.params or {}).sort == "title" and _("Title A–Z") or _("Reading order"),
|
||||
order_label = self:listSortLabel(context.params or {}),
|
||||
states = states, indexes = indexes, current_index = cur, next_book = next_book,
|
||||
range = #books > 0 and T(_("Books %1 – %2"), first, last) or nil,
|
||||
next_minutes = next_book and self:listMinutesLeft(next_book),
|
||||
@@ -1289,7 +1369,8 @@ function CatalogLists:updateListPage(select_number, no_recalculate_dimen)
|
||||
local body, m = self:listBody(kind, context)
|
||||
-- Offline the band stands in for the toolbar: sorting, filtering and the
|
||||
-- view all need the server.
|
||||
local has_toolbar = (kind == "books" or kind == "reading" or kind == "authors" or kind == "series") and not context.list_offline
|
||||
local has_toolbar = (kind == "books" or kind == "reading" or kind == "authors" or kind == "series" or kind == "seriespage")
|
||||
and not context.list_offline
|
||||
local toolbar = nil
|
||||
if has_toolbar then
|
||||
local buttons = self:listToolbar(kind, context)
|
||||
|
||||
@@ -42,6 +42,7 @@ CatalogUtil.SORTS = {
|
||||
CatalogUtil.SORT_LABELS = {
|
||||
title = _("Title"),
|
||||
author = _("Author"),
|
||||
published = _("Publication date"),
|
||||
recently_added = _("Recently added"),
|
||||
recently_updated = _("Recently updated"),
|
||||
recently_read = _("Recently read"),
|
||||
@@ -51,6 +52,7 @@ CatalogUtil.SORT_LABELS = {
|
||||
CatalogUtil.NATURAL_ORDER = {
|
||||
title = "asc",
|
||||
author = "asc",
|
||||
published = "asc",
|
||||
series = "asc",
|
||||
recently_added = "desc",
|
||||
recently_updated = "desc",
|
||||
@@ -60,6 +62,7 @@ CatalogUtil.NATURAL_ORDER = {
|
||||
CatalogUtil.SORT_KIND = {
|
||||
title = "alpha",
|
||||
author = "alpha",
|
||||
published = "time",
|
||||
series = "alpha",
|
||||
recently_added = "time",
|
||||
recently_updated = "time",
|
||||
|
||||
@@ -121,8 +121,8 @@ end
|
||||
-- with the list alone (landscape keeps the summary column on every page).
|
||||
-- A grid shrinks its covers to keep a series on one page only down to three
|
||||
-- quarters of the All books size; past that it pages at full size.
|
||||
function Pages.seriesLayout(screen_w, screen_h, view, n, offline)
|
||||
local f = ListView.frame(screen_w, screen_h, { toolbar = false, offline = offline })
|
||||
function Pages.seriesLayout(screen_w, screen_h, view, n, offline, toolbar)
|
||||
local f = ListView.frame(screen_w, screen_h, { toolbar = toolbar == true, offline = offline })
|
||||
local side = BoardMetrics.get().w > BoardMetrics.get().h
|
||||
local side_w = side and math.floor(f.w * 0.38 + 0.5) or f.w
|
||||
local list_w = side and (f.w - side_w - px(28)) or f.w
|
||||
@@ -155,7 +155,7 @@ end
|
||||
-- puts the summary beside the books.
|
||||
function Pages.seriesBody(m, f)
|
||||
local all_books = m.entries
|
||||
local layout = Pages.seriesLayout(m.screen_w, m.screen_h, m.view, #all_books, m.offline ~= nil)
|
||||
local layout = Pages.seriesLayout(m.screen_w, m.screen_h, m.view, #all_books, m.offline ~= nil, m.toolbar == true)
|
||||
local page = math.max(1, math.min(layout.pages, m.local_page or 1))
|
||||
local books = {}
|
||||
local start = page == 1 and 1 or (layout.first + (page - 2) * layout.rest + 1)
|
||||
|
||||
@@ -104,7 +104,7 @@ assertEqual(CatalogDetail.detailSeriesText({ seriesIndex = 3 }), nil, "an index
|
||||
|
||||
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, "title", "a series list sorts by title")
|
||||
assertEqual(by_id.sort, "series", "a series list sorts by series position")
|
||||
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")
|
||||
|
||||
@@ -5,10 +5,11 @@ package.loaded["document/documentregistry"] = {}
|
||||
package.loaded["util"] = {}
|
||||
for _, name in ipairs({ "device", "ui/widget/infomessage", "ui/network/manager", "ui/uimanager",
|
||||
"datetime", "logger", "bookorbit_board_data", "bookorbit_board_metrics",
|
||||
"bookorbit_capabilities", "bookorbit_list_cache", "bookorbit_list_data",
|
||||
"bookorbit_list_cache", "bookorbit_list_data",
|
||||
"bookorbit_list_pages", "bookorbit_list_view" }) do package.loaded[name] = {} end
|
||||
|
||||
local Lists = require("bookorbit_catalog_lists")
|
||||
local Capabilities = require("bookorbit_capabilities")
|
||||
|
||||
local requested, saved = {}, {}
|
||||
local catalog = setmetatable({
|
||||
@@ -61,4 +62,52 @@ assert(model.select == true, "a series page exposes active selection mode to its
|
||||
assert(model.selected(first_book) == true, "a series page marks the selected book")
|
||||
assert(model.selected(second_book) == false, "a series page leaves unselected books unmarked")
|
||||
|
||||
function catalog:updateItems() end
|
||||
function catalog:runConnected(fn) return fn() end
|
||||
local applied
|
||||
function catalog:fetchBookPage(params, title, push) applied = { params = params, title = title, push = push } end
|
||||
|
||||
Capabilities.reset()
|
||||
Capabilities.remember(catalog.client, { capabilities = { Lists.CAPABILITY, Lists.PUBLISHED_SORT_CAPABILITY } })
|
||||
catalog.current_context = { kind = "books", title = "Series", params = { seriesId = 42, sort = "series", order = "asc" } }
|
||||
catalog:listOpenSheet("sort", "sort")
|
||||
local sheet_model = catalog:listSortSheetModel(catalog.current_context.list_sheet)
|
||||
assert(table.concat(sheet_model.sorts.labels, "|") == "Series order|Publication date|Title|Last read|Added to library",
|
||||
"a capable server exposes every series sort")
|
||||
assert(table.concat(sheet_model.order.labels, "|") == "First to last|Last to first", "series position has purpose-specific directions")
|
||||
|
||||
catalog:listRun({ kind = "sheet-sort", index = 2 })
|
||||
assert(catalog.current_context.list_sheet.staged.sort == "published", "publication date can be selected")
|
||||
assert(catalog.current_context.list_sheet.staged.order == "asc", "publication date starts oldest first")
|
||||
catalog:listRun({ kind = "sheet-order", index = 2 })
|
||||
catalog:listRun({ kind = "sheet-show", index = 2 })
|
||||
catalog:listApplySheet()
|
||||
assert(applied.params.sort == "published" and applied.params.order == "desc", "Apply reloads the series with the selected sorting")
|
||||
assert(applied.params.readStatus == "unread", "Apply keeps read status as a filter")
|
||||
assert(applied.params.page == 1 and applied.params.size == nil and applied.push == false, "Apply restarts the same series list")
|
||||
|
||||
Capabilities.remember(catalog.client, { capabilities = { Lists.CAPABILITY } })
|
||||
catalog.current_context = { kind = "books", title = "Series", params = { seriesId = 42, sort = "series" } }
|
||||
catalog:listOpenSheet("sort", "sort")
|
||||
sheet_model = catalog:listSortSheetModel(catalog.current_context.list_sheet)
|
||||
assert(table.concat(sheet_model.sorts.labels, "|") == "Series order|Title|Last read|Added to library",
|
||||
"an older server is never offered the unsupported publication sort")
|
||||
catalog.current_context.list_sheet.staged = { sort = "title", order = "desc", readStatus = "finished" }
|
||||
catalog:listResetSheet()
|
||||
assert(catalog.current_context.list_sheet.staged.sort == "series" and catalog.current_context.list_sheet.staged.order == "asc",
|
||||
"Reset restores first-to-last series order")
|
||||
assert(catalog.current_context.list_sheet.staged.readStatus == nil, "Reset clears the series filter")
|
||||
|
||||
local source_file = assert(io.open("koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua", "r"))
|
||||
local source = source_file:read("*a")
|
||||
source_file:close()
|
||||
local params_source = assert(source:match("(function BookOrbitCatalog:paramsForEntry.-\nend)\n\n%-%- Assumes"))
|
||||
local env = { BookOrbitCatalog = {} }
|
||||
setmetatable(env, { __index = _G })
|
||||
local chunk = assert(loadstring(params_source))
|
||||
setfenv(chunk, env)
|
||||
chunk()
|
||||
local series_params = env.BookOrbitCatalog.paramsForEntry({}, "series", { seriesId = 42, id = "series:42" })
|
||||
assert(series_params.seriesId == 42 and series_params.sort == "series", "opening a series defaults to membership order")
|
||||
|
||||
print("bookorbit_series_paging_test.lua: ok")
|
||||
|
||||
@@ -198,7 +198,7 @@ export interface TestKoreaderConnectionResult {
|
||||
export type KoreaderCatalogSection =
|
||||
"libraries" | "collections" | "smart-scopes" | "authors" | "series" | "search" | "recent" | "all-books" | "continue-reading";
|
||||
|
||||
export type KoreaderCatalogSort = "title" | "author" | "recently_added" | "recently_updated" | "recently_read" | "series";
|
||||
export type KoreaderCatalogSort = "title" | "author" | "published" | "recently_added" | "recently_updated" | "recently_read" | "series";
|
||||
|
||||
export type KoreaderCatalogSortOrder = "asc" | "desc";
|
||||
|
||||
@@ -591,6 +591,7 @@ export type KoreaderPluginCapability =
|
||||
| "readAloudDeliveryVariants"
|
||||
| "readAloudDeliverySettings"
|
||||
| "catalogListsV2"
|
||||
| "catalogPublishedSort"
|
||||
| "sidecarState"
|
||||
| "annotationPendingBooks";
|
||||
|
||||
|
||||
@@ -95,6 +95,15 @@ describe('KoreaderCatalogSectionQueryDto', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('KoreaderCatalogBooksQueryDto sorting', () => {
|
||||
it('accepts publication-date sorting in either direction', async () => {
|
||||
for (const order of ['asc', 'desc']) {
|
||||
const dto = plainToInstance(KoreaderCatalogBooksQueryDto, { sort: 'published', order });
|
||||
await expect(validate(dto), order).resolves.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KoreaderCatalogBooksQueryDto buckets flag', () => {
|
||||
it.each([
|
||||
['1', true],
|
||||
|
||||
@@ -17,6 +17,7 @@ import { KOREADER_DEVICE_ID_REGEX } from './koreader-device-param.dto';
|
||||
export const KOREADER_CATALOG_SORTS = [
|
||||
'title',
|
||||
'author',
|
||||
'published',
|
||||
'recently_added',
|
||||
'recently_updated',
|
||||
'recently_read',
|
||||
|
||||
@@ -876,6 +876,12 @@ describe('KoreaderCatalogService', () => {
|
||||
|
||||
await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { sort: 'recently_read', order: 'asc' }));
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenLastCalledWith(11, 'last_read_asc', 1, 20, {}, false, user.contentFilters);
|
||||
|
||||
await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { sort: 'published' }));
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenLastCalledWith(11, 'published_asc', 1, 20, {}, false, user.contentFilters);
|
||||
|
||||
await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { sort: 'published', order: 'desc' }));
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenLastCalledWith(11, 'published_desc', 1, 20, {}, false, user.contentFilters);
|
||||
});
|
||||
|
||||
it('reports when a book was last opened, the newest across its files', async () => {
|
||||
|
||||
@@ -153,6 +153,7 @@ const MANIFEST_CURSOR_CONTRACT_CHANGED = Symbol('manifest-cursor-contract-change
|
||||
const NATURAL_SORT_ORDER: Record<KoreaderCatalogSort, KoreaderCatalogSortOrder> = {
|
||||
title: 'asc',
|
||||
author: 'asc',
|
||||
published: 'asc',
|
||||
series: 'asc',
|
||||
recently_added: 'desc',
|
||||
recently_updated: 'desc',
|
||||
@@ -1562,6 +1563,8 @@ export class KoreaderCatalogService {
|
||||
return descending ? 'title_desc' : 'title_asc';
|
||||
case 'author':
|
||||
return descending ? 'author_desc' : 'author_asc';
|
||||
case 'published':
|
||||
return descending ? 'published_desc' : 'published_asc';
|
||||
case 'recently_updated':
|
||||
return descending ? 'updated' : 'updated_asc';
|
||||
case 'recently_read':
|
||||
|
||||
@@ -175,6 +175,7 @@ describe('KoreaderPackageService', () => {
|
||||
expect(result.capabilities).toContain('catalogBulkManifest');
|
||||
expect(result.capabilities).toContain('bookmarkSync');
|
||||
expect(result.capabilities).toContain('catalogListsV2');
|
||||
expect(result.capabilities).toContain('catalogPublishedSort');
|
||||
});
|
||||
|
||||
it('returns pluginVersion=unknown when the plugin source dir does not exist', async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = [
|
||||
'readAloudDeliveryVariants',
|
||||
'readAloudDeliverySettings',
|
||||
'catalogListsV2',
|
||||
'catalogPublishedSort',
|
||||
'sidecarState',
|
||||
'annotationPendingBooks',
|
||||
];
|
||||
|
||||
@@ -870,6 +870,8 @@ describe('OpdsBookService', () => {
|
||||
'title_desc',
|
||||
'author_asc',
|
||||
'author_desc',
|
||||
'published_asc',
|
||||
'published_desc',
|
||||
'series_asc',
|
||||
'series_desc',
|
||||
] as const;
|
||||
@@ -905,6 +907,19 @@ describe('OpdsBookService', () => {
|
||||
expect(orderText(ids)).toMatch(/DESC NULLS LAST, "user_book_status"."updated_at" DESC NULLS LAST, "books"."id" ASC$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['published_asc', 'ASC'],
|
||||
['published_desc', 'DESC'],
|
||||
] as const)('sorts %s by full date, then year fallback, with undated books last and stable ties', async (sortOrder, direction) => {
|
||||
const { service, db } = makeService([[], [{ total: 0 }]]);
|
||||
|
||||
await testable(service).paginatedBookQuery({ kind: 'where' }, sortOrder, 1, 25, 7);
|
||||
|
||||
expect(orderText(selectChains(db)[0]!)).toBe(
|
||||
`coalesce("book_metadata"."published_date", make_date("book_metadata"."published_year", 1, 1)) ${direction} NULLS LAST, "book_metadata"."title" ASC NULLS LAST, "books"."id" ASC`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps metadata, authors, and files into ordered OPDS entries', async () => {
|
||||
const now = new Date('2026-04-15T00:00:00.000Z');
|
||||
const { service } = makeService([
|
||||
|
||||
@@ -77,6 +77,8 @@ type OpdsSortOrder =
|
||||
| 'title_desc'
|
||||
| 'author_asc'
|
||||
| 'author_desc'
|
||||
| 'published_asc'
|
||||
| 'published_desc'
|
||||
| 'series_asc'
|
||||
| 'series_desc';
|
||||
|
||||
@@ -94,6 +96,16 @@ const OPDS_SORT_MAP: Record<Exclude<OpdsSortOrder, LastReadSortOrder>, SQL[]> =
|
||||
title_desc: [sql`${bookMetadata.title} DESC NULLS LAST`, sql`${books.id} ASC`],
|
||||
author_asc: [sql`${books.primaryAuthorSortName} ASC NULLS LAST`, sql`${bookMetadata.title} ASC NULLS LAST`, sql`${books.id} ASC`],
|
||||
author_desc: [sql`${books.primaryAuthorSortName} DESC NULLS LAST`, sql`${bookMetadata.title} ASC NULLS LAST`, sql`${books.id} ASC`],
|
||||
published_asc: [
|
||||
sql`coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1)) ASC NULLS LAST`,
|
||||
sql`${bookMetadata.title} ASC NULLS LAST`,
|
||||
sql`${books.id} ASC`,
|
||||
],
|
||||
published_desc: [
|
||||
sql`coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1)) DESC NULLS LAST`,
|
||||
sql`${bookMetadata.title} ASC NULLS LAST`,
|
||||
sql`${books.id} ASC`,
|
||||
],
|
||||
series_asc: [sql`${bookMetadata.seriesName} ASC NULLS LAST`, ...seriesIndexOrderBy(bookMetadata.seriesIndex, 'ASC'), sql`${books.id} ASC`],
|
||||
series_desc: [sql`${bookMetadata.seriesName} DESC NULLS LAST`, ...seriesIndexOrderBy(bookMetadata.seriesIndex, 'DESC'), sql`${books.id} ASC`],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user