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:
neon
2026-10-31 02:10:36 +00:00
parent 14caaa58b9
commit ee2974459a
16 changed files with 206 additions and 24 deletions
@@ -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")
+2 -1
View File
@@ -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`],
};