diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua index 3332e0c9d..544e529ab 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua @@ -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 diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua index 28d38e14a..e5075d59f 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua @@ -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) diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_lists.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_lists.lua index c78db3f46..779143ccf 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_lists.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_lists.lua @@ -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) diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua index 5d7a9cda6..322ba834d 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_util.lua @@ -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", diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua index 9f8ae5b45..a1f58e35a 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_list_pages.lua @@ -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) diff --git a/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua b/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua index 22af04879..c3a5451a7 100644 --- a/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua +++ b/koreader-plugin/spec/bookorbit_detail_info_rows_test.lua @@ -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") diff --git a/koreader-plugin/spec/bookorbit_series_paging_test.lua b/koreader-plugin/spec/bookorbit_series_paging_test.lua index 3b1e677d1..6e85cce66 100644 --- a/koreader-plugin/spec/bookorbit_series_paging_test.lua +++ b/koreader-plugin/spec/bookorbit_series_paging_test.lua @@ -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") diff --git a/packages/types/src/koreader.ts b/packages/types/src/koreader.ts index 4dc2c02c3..ea804dcde 100644 --- a/packages/types/src/koreader.ts +++ b/packages/types/src/koreader.ts @@ -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"; diff --git a/server/src/modules/koreader/dto/koreader-catalog-query.dto.test.ts b/server/src/modules/koreader/dto/koreader-catalog-query.dto.test.ts index 123be002c..6cfe808e1 100644 --- a/server/src/modules/koreader/dto/koreader-catalog-query.dto.test.ts +++ b/server/src/modules/koreader/dto/koreader-catalog-query.dto.test.ts @@ -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], diff --git a/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts b/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts index eaff2ef35..90034649d 100644 --- a/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts +++ b/server/src/modules/koreader/dto/koreader-catalog-query.dto.ts @@ -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', diff --git a/server/src/modules/koreader/koreader-catalog.service.test.ts b/server/src/modules/koreader/koreader-catalog.service.test.ts index 558d0eda6..492d6bd31 100644 --- a/server/src/modules/koreader/koreader-catalog.service.test.ts +++ b/server/src/modules/koreader/koreader-catalog.service.test.ts @@ -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 () => { diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index dda36b370..0a55cab5e 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -153,6 +153,7 @@ const MANIFEST_CURSOR_CONTRACT_CHANGED = Symbol('manifest-cursor-contract-change const NATURAL_SORT_ORDER: Record = { 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': diff --git a/server/src/modules/koreader/koreader-package.service.test.ts b/server/src/modules/koreader/koreader-package.service.test.ts index 47900c803..e829e5576 100644 --- a/server/src/modules/koreader/koreader-package.service.test.ts +++ b/server/src/modules/koreader/koreader-package.service.test.ts @@ -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 () => { diff --git a/server/src/modules/koreader/koreader-package.service.ts b/server/src/modules/koreader/koreader-package.service.ts index 21c2c1880..160ca9e52 100644 --- a/server/src/modules/koreader/koreader-package.service.ts +++ b/server/src/modules/koreader/koreader-package.service.ts @@ -33,6 +33,7 @@ const SERVER_CAPABILITIES: readonly KoreaderPluginCapability[] = [ 'readAloudDeliveryVariants', 'readAloudDeliverySettings', 'catalogListsV2', + 'catalogPublishedSort', 'sidecarState', 'annotationPendingBooks', ]; diff --git a/server/src/modules/opds/__tests__/opds-book.service.test.ts b/server/src/modules/opds/__tests__/opds-book.service.test.ts index 509f8297c..dc277c84c 100644 --- a/server/src/modules/opds/__tests__/opds-book.service.test.ts +++ b/server/src/modules/opds/__tests__/opds-book.service.test.ts @@ -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([ diff --git a/server/src/modules/opds/opds-book.service.ts b/server/src/modules/opds/opds-book.service.ts index 5e83cd524..b57e2ae07 100644 --- a/server/src/modules/opds/opds-book.service.ts +++ b/server/src/modules/opds/opds-book.service.ts @@ -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, 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`], };