feat(kobo): add catalog bulk download and selection to KOReader plugin

Add support for selecting multiple books and downloading entire lists, pages, or selections in bulk. This includes long-press actions, a dedicated bulk download queue with retry and cancel support, and format preset configurations.

The commit also simplifies the OPDS random book query using ORDER BY random() and adjusts the Nest dev CLI config to disable the Swagger plugin for faster restarts.

Closes #478
This commit is contained in:
neon
2026-07-01 00:18:49 +00:00
parent 6f2fe8612e
commit 68a395095a
15 changed files with 1634 additions and 160 deletions
+8 -2
View File
@@ -2,7 +2,7 @@
Syncs your KOReader reading life into BookOrbit: Syncs your KOReader reading life into BookOrbit:
- Native BookOrbit catalog browsing from KOReader: a reading-first dashboard with compact Continue reading rows, goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series, all books and scoped or global search. The dashboard can auto-open on startup and falls back to its last cached snapshot when offline. Browse books as a cover mosaic or a detailed list, with read-status, reading-progress bars and on-device indicators, read-status and format filters, a configurable grid, persisted view/sort/grid, recent-search history, loading feedback, download progress and inline retry on failures. Navigation runs off the UI thread (cancellable, no freeze) and the next page's covers are prefetched. The detail page shows genres, tags, page count and an expandable full description, and can set read status and star rating directly (synced back like any other status/rating change). Series open as an ordered read-through view with a "read N/total" header. Authors and series lists are paginated with footer arrows and filtered from the catalog action menu. Downloads are linked to BookOrbit sync after the file is saved, and books already on the device offer a "Read" action that opens the local file. The thumbnail cache is bounded and evicts the oldest covers. - Native BookOrbit catalog browsing from KOReader: a reading-first dashboard with compact Continue reading rows, goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series, all books and scoped or global search. The dashboard can auto-open on startup and falls back to its last cached snapshot when offline. Browse books as a cover mosaic or a detailed list, with read-status, reading-progress bars and on-device indicators, read-status and format filters, a configurable grid, persisted view/sort/grid, recent-search history, loading feedback, download progress and inline retry on failures. Navigation runs off the UI thread (cancellable, no freeze) and the next page's covers are prefetched. The detail page shows genres, tags, page count and an expandable full description, and can set read status and star rating directly (synced back like any other status/rating change). Series open as an ordered read-through view with a "read N/total" header. Authors and series lists are paginated with footer arrows and filtered from the catalog action menu. Downloads are linked to BookOrbit sync after the file is saved, and books already on the device offer a "Read" action that opens the local file. Bulk downloads can run from the current page, all books matching a list, or selected books across pages. The thumbnail cache is bounded and evicts the oldest covers.
- Live reading progress of the open book (pull on open with conflict prompt, optional periodic push every N page turns, push on close and suspend), compatible with the kosync flow BookOrbit already speaks. - Live reading progress of the open book (pull on open with conflict prompt, optional periodic push every N page turns, push on close and suspend), compatible with the kosync flow BookOrbit already speaks.
- Reading statistics: the raw per-page time events from KOReader's statistics database. BookOrbit turns them into reading sessions and daily stats, so they show up in the existing dashboards. - Reading statistics: the raw per-page time events from KOReader's statistics database. BookOrbit turns them into reading sessions and daily stats, so they show up in the existing dashboards.
- Highlights and notes, two ways: device highlights appear in the BookOrbit web reader as native highlights, and highlights, note edits and deletions made in BookOrbit come back to the device. - Highlights and notes, two ways: device highlights appear in the BookOrbit web reader as native highlights, and highlights, note edits and deletions made in BookOrbit come back to the device.
@@ -80,7 +80,7 @@ Years of statistics upload in batches of 500 events. Roughly 200k events means a
- Catalog browser: "Open dashboard" appears in the Tools menu after login and opens the dashboard root. The dashboard shows compact Continue reading rows, reading goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series and all books. Tapping dashboard books/highlights opens the book detail; tapping shortcuts opens the matching existing catalog screen. - Catalog browser: "Open dashboard" appears in the Tools menu after login and opens the dashboard root. The dashboard shows compact Continue reading rows, reading goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series and all books. Tapping dashboard books/highlights opens the book detail; tapping shortcuts opens the matching existing catalog screen.
- Catalog dashboard cache: open the dashboard successfully, turn wifi off, then open it again through startup auto-open; the last successful dashboard appears with an offline-cache label. Against an older BookOrbit server without the dashboard endpoint, the plugin falls back to the old catalog root and reports that the server needs an update. - Catalog dashboard cache: open the dashboard successfully, turn wifi off, then open it again through startup auto-open; the last successful dashboard appears with an offline-cache label. Against an older BookOrbit server without the dashboard endpoint, the plugin falls back to the old catalog root and reports that the server needs an update.
- Startup dashboard: with "Open dashboard on startup" set to Off, restart does not open it. With File manager only, restarting into the file manager opens the dashboard once. With Every startup, restarting into the file manager or reader opens the dashboard once. A fresh preconfigured install still shows the provision confirmation without auto-opening over it. - Startup dashboard: with "Open dashboard on startup" set to Off, restart does not open it. With File manager only, restarting into the file manager opens the dashboard once. With Every startup, restarting into the file manager or reader opens the dashboard once. A fresh preconfigured install still shows the provision confirmation without auto-opening over it.
- Catalog search: the title-bar left button opens the catalog action menu everywhere. Dashboard and section pages offer "Search BookOrbit"; book lists offer "Search in this list". Search is no longer a row in the root list. - Catalog search: the title-bar left button opens the catalog action menu everywhere. Dashboard and section pages offer "Search BookOrbit"; book lists offer "Search in this list". Search is no longer a row in the root list. The title bar has no close button; use "Close BookOrbit" from the menu.
- Catalog view and filters (actions menu): toggle mosaic/list view, change the mosaic grid size, filter by read status (all/unread/reading/finished) and by format, sort, and change order (asc/desc). View mode, sort and grid size persist across sessions; the active sort, direction and filters show in the page subtitle. - Catalog view and filters (actions menu): toggle mosaic/list view, change the mosaic grid size, filter by read status (all/unread/reading/finished) and by format, sort, and change order (asc/desc). View mode, sort and grid size persist across sessions; the active sort, direction and filters show in the page subtitle.
- Catalog responsiveness: tapping any section, book page or detail shows a dismissable loading message and does not freeze the reader (network runs in a subprocess); tapping the loading message cancels the request. The next page's covers are prefetched a second after a page settles, so paging forward shows covers immediately. - Catalog responsiveness: tapping any section, book page or detail shows a dismissable loading message and does not freeze the reader (network runs in a subprocess); tapping the loading message cancels the request. The next page's covers are prefetched a second after a page settles, so paging forward shows covers immediately.
- Catalog detail edits (detail page actions menu): "Set read status" and "Set rating" write straight to BookOrbit and update the page in place; against an older server without these endpoints the action reports it is unsupported instead of erroring. "Description" opens the complete text, and "Genres"/"Tags" list every value (the detail page itself only shows the first few with a "+N more" hint). - Catalog detail edits (detail page actions menu): "Set read status" and "Set rating" write straight to BookOrbit and update the page in place; against an older server without these endpoints the action reports it is unsupported instead of erroring. "Description" opens the complete text, and "Genres"/"Tags" list every value (the detail page itself only shows the first few with a "+N more" hint).
@@ -91,6 +91,12 @@ Years of statistics upload in batches of 500 events. Roughly 200k events means a
- Catalog search history: the search dialog offers a "Recent" button to re-run one of the last searches (stored locally). - Catalog search history: the search dialog offers a "Recent" button to re-run one of the last searches (stored locally).
- Catalog feedback: section/page/detail fetches show a loading message; failures show an inline "Retry" dialog; downloads show throttled byte/percent progress. - Catalog feedback: section/page/detail fetches show a loading message; failures show an inline "Retry" dialog; downloads show throttled byte/percent progress.
- Catalog download: open a book detail, tap "Download" for the default folder/name, confirm overwrite if needed, watch the download progress, then accept "Open now". Use "Download options" in the detail action menu to change folder/name. The downloaded file is matched by partial MD5 and linked in `bookorbit_sync_state.lua`; the "On device" badge refreshes after a successful link. - Catalog download: open a book detail, tap "Download" for the default folder/name, confirm overwrite if needed, watch the download progress, then accept "Open now". Use "Download options" in the detail action menu to change folder/name. The downloaded file is matched by partial MD5 and linked in `bookorbit_sync_state.lua`; the "On device" badge refreshes after a successful link.
- Catalog long-press actions: hold a dashboard cover, mosaic card or list row. The detail-backed action sheet offers details, read/open when on device, download, download options, read status, rating, description, genres and tags. On normal book lists it also offers Select.
- Catalog selected download: in a normal book list, hold a book and choose "Select", or choose "Select books" from the action menu. Select books across multiple pages, verify the top-right selected badges, selected count and disabled download button while nothing is selected. With books selected, the title-bar search icon becomes a download button and opens the same confirmation as "Download selected". The refresh icon clears selected books; when nothing is selected, it exits selection mode. Returning to the dashboard exits selection mode and restores search/refresh. "Select page", "Clear" and "Exit selection" work from the selection action menu.
- Catalog list bulk download: from a filtered list or series, run "Download this page" and "Download all in this list". The confirmation shows the target folder, format preset, existing-file policy and total count. Broad "All Books" downloads require the same confirmation.
- Catalog bulk settings: in the catalog action menu, change "Bulk download settings" between EPUB/PDF/Comics format presets and Skip/Overwrite existing-file policies. Verify the next queue uses the selected policy.
- Catalog bulk failure handling: simulate a network/server failure or device write failure during a queue. The queue continues where possible, deletes failed partial downloads through the download client, shows failed titles in the completion summary, and "Retry failed" retries only the failed books.
- Catalog bulk cancellation: start a multi-book queue, tap "Cancel after current file", and verify the active transfer finishes or fails before the queue stops with a stopped summary.
- Catalog read vs download: when a book is already on the device, the detail page shows a primary "Read" button that opens the local file. "Download" is shown next to it only when there are formats not yet on the device, starts with the default folder/name, and is hidden once every supported format is downloaded. The detail action menu offers "Download options" for folder/name changes. The file chooser marks already-downloaded formats as "Open" instead of "Download". On-device state is refreshed when the detail opens and after a successful download. - Catalog read vs download: when a book is already on the device, the detail page shows a primary "Read" button that opens the local file. "Download" is shown next to it only when there are formats not yet on the device, starts with the default folder/name, and is hidden once every supported format is downloaded. The detail action menu offers "Download options" for folder/name changes. The file chooser marks already-downloaded formats as "Open" instead of "Download". On-device state is refreshed when the detail opens and after a successful download.
- Custom menu order: remove the marker line and shuffle the tools list; on restart only the `bookorbit_sync` id is re-inserted, the rest is preserved. A syntactically broken file is left untouched (warning in the log). - Custom menu order: remove the marker line and shuffle the tools list; on restart only the `bookorbit_sync` id is re-inserted, the rest is preserved. A syntactically broken file is left untouched (warning in the log).
- Login: wrong password shows a login error; server URL with and without trailing `/` and with `/api/v1` already appended all work. - Login: wrong password shows a login error; server URL with and without trailing `/` and with `/api/v1` already appended all work.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M24 7v24" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square"/>
<path d="M14 22l10 10 10-10" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M10 35v6h28v-6" fill="none" stroke="#000000" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -6,9 +6,10 @@ pages render as KOReader-style menu pages with a cover mosaic and progressive
thumbnail loading. thumbnail loading.
The controller is split across modules: bookorbit_catalog_util (constants and The controller is split across modules: bookorbit_catalog_util (constants and
pure helpers), bookorbit_catalog_widgets (cover and item widgets) and pure helpers), bookorbit_catalog_widgets (cover and item widgets),
bookorbit_catalog_download (download manager mixin). Navigation fetches run off bookorbit_catalog_download (single-file downloads) and
the UI thread via Trapper so taps never freeze the reader. bookorbit_catalog_bulk_download (bulk queues). Navigation fetches run off the
UI thread via Trapper so taps never freeze the reader.
]] ]]
local BD = require("ui/bidi") local BD = require("ui/bidi")
@@ -23,6 +24,7 @@ local Geom = require("ui/geometry")
local HorizontalGroup = require("ui/widget/horizontalgroup") local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan") local HorizontalSpan = require("ui/widget/horizontalspan")
local IconButton = require("ui/widget/iconbutton") local IconButton = require("ui/widget/iconbutton")
local IconWidget = require("ui/widget/iconwidget")
local InfoMessage = require("ui/widget/infomessage") local InfoMessage = require("ui/widget/infomessage")
local InputDialog = require("ui/widget/inputdialog") local InputDialog = require("ui/widget/inputdialog")
local Menu = require("ui/widget/menu") local Menu = require("ui/widget/menu")
@@ -47,6 +49,7 @@ local BookOrbitState = require("bookorbit_state")
local CatalogUtil = require("bookorbit_catalog_util") local CatalogUtil = require("bookorbit_catalog_util")
local CatalogWidgets = require("bookorbit_catalog_widgets") local CatalogWidgets = require("bookorbit_catalog_widgets")
local CatalogDownload = require("bookorbit_catalog_download") local CatalogDownload = require("bookorbit_catalog_download")
local CatalogBulkDownload = require("bookorbit_catalog_bulk_download")
local Screen = Device.screen local Screen = Device.screen
local DGENERIC_ICON_SIZE = G_defaults:readSetting("DGENERIC_ICON_SIZE") local DGENERIC_ICON_SIZE = G_defaults:readSetting("DGENERIC_ICON_SIZE")
@@ -93,6 +96,8 @@ local DETAIL_TAGS_INLINE = 6
-- Soft cap on the catalog thumbnail cache (cache/bookorbit). Oldest covers are -- Soft cap on the catalog thumbnail cache (cache/bookorbit). Oldest covers are
-- evicted past this many files so the cache cannot grow without bound. -- evicted past this many files so the cache cannot grow without bound.
local THUMBNAIL_CACHE_MAX_FILES = 600 local THUMBNAIL_CACHE_MAX_FILES = 600
local DOWNLOAD_ICON = "appbar.filebrowser"
local DOWNLOAD_ICON_FILE = "bookorbit.download.svg"
local function showError(err) local function showError(err)
local text local text
@@ -110,15 +115,47 @@ local function isDashboardUnsupported(err)
return err == 404 or err == 405 return err == 404 or err == 405
end end
local BookOrbitIconButton = IconButton:extend{ file = nil }
function BookOrbitIconButton:init()
if not self.file then
return IconButton.init(self)
end
self.image = IconWidget:new{
file = self.file,
rotation_angle = self.icon_rotation_angle,
width = self.width,
height = self.height,
}
self.show_parent = self.show_parent or self
self.horizontal_group = HorizontalGroup:new{}
table.insert(self.horizontal_group, HorizontalSpan:new{})
table.insert(self.horizontal_group, self.image)
table.insert(self.horizontal_group, HorizontalSpan:new{})
self.button = VerticalGroup:new{}
table.insert(self.button, VerticalSpan:new{})
table.insert(self.button, self.horizontal_group)
table.insert(self.button, VerticalSpan:new{})
self[1] = self.button
self:update()
end
local BookOrbitTitleBar = TitleBar:extend{ local BookOrbitTitleBar = TitleBar:extend{
search_icon = nil, search_icon = nil,
search_icon_file = nil,
search_icon_tap_callback = nil, search_icon_tap_callback = nil,
search_icon_hold_callback = nil, search_icon_hold_callback = nil,
search_icon_allow_flash = true, search_icon_allow_flash = true,
search_icon_enabled = true,
refresh_icon = nil, refresh_icon = nil,
refresh_icon_tap_callback = nil, refresh_icon_tap_callback = nil,
refresh_icon_hold_callback = nil, refresh_icon_hold_callback = nil,
refresh_icon_allow_flash = true, refresh_icon_allow_flash = true,
refresh_icon_enabled = true,
} }
function BookOrbitTitleBar:init() function BookOrbitTitleBar:init()
@@ -138,8 +175,9 @@ function BookOrbitTitleBar:init()
end end
if self.search_icon then if self.search_icon then
self.search_button = IconButton:new{ self.search_button = BookOrbitIconButton:new{
icon = self.search_icon, icon = self.search_icon,
file = self.search_icon_file,
width = icon_size, width = icon_size,
height = icon_size, height = icon_size,
padding = button_padding, padding = button_padding,
@@ -148,6 +186,7 @@ function BookOrbitTitleBar:init()
callback = self.search_icon_tap_callback, callback = self.search_icon_tap_callback,
hold_callback = self.search_icon_hold_callback, hold_callback = self.search_icon_hold_callback,
allow_flash = self.search_icon_allow_flash, allow_flash = self.search_icon_allow_flash,
enabled = self.search_icon_enabled ~= false,
show_parent = self.show_parent, show_parent = self.show_parent,
} }
table.insert(self, self.search_button) table.insert(self, self.search_button)
@@ -165,6 +204,7 @@ function BookOrbitTitleBar:init()
callback = self.refresh_icon_tap_callback, callback = self.refresh_icon_tap_callback,
hold_callback = self.refresh_icon_hold_callback, hold_callback = self.refresh_icon_hold_callback,
allow_flash = self.refresh_icon_allow_flash, allow_flash = self.refresh_icon_allow_flash,
enabled = self.refresh_icon_enabled ~= false,
show_parent = self.show_parent, show_parent = self.show_parent,
} }
table.insert(self, self.refresh_button) table.insert(self, self.refresh_button)
@@ -198,6 +238,7 @@ local BookOrbitCatalog = Menu:extend{
} }
CatalogDownload.install(BookOrbitCatalog) CatalogDownload.install(BookOrbitCatalog)
CatalogBulkDownload.install(BookOrbitCatalog)
local Menu_recalculateDimen = Menu._recalculateDimen local Menu_recalculateDimen = Menu._recalculateDimen
local Menu_updateItems = Menu.updateItems local Menu_updateItems = Menu.updateItems
@@ -214,9 +255,11 @@ function BookOrbitCatalog:init()
self.thumbnail_generation = 0 self.thumbnail_generation = 0
self.thumbnail_failures = {} self.thumbnail_failures = {}
self.settings = self.settings or {} self.settings = self.settings or {}
self:initBulkDownloadState()
self.view_mode = self.settings.catalog_view_mode == "list" and "list" or "mosaic" self.view_mode = self.settings.catalog_view_mode == "list" and "list" or "mosaic"
self.grid_cols = self:sanitizeGridValue(self.settings.catalog_grid_cols, DEFAULT_GRID_COLUMNS) self.grid_cols = self:sanitizeGridValue(self.settings.catalog_grid_cols, DEFAULT_GRID_COLUMNS)
self.grid_rows = self:sanitizeGridValue(self.settings.catalog_grid_rows, DEFAULT_GRID_ROWS) self.grid_rows = self:sanitizeGridValue(self.settings.catalog_grid_rows, DEFAULT_GRID_ROWS)
self.mosaic_show_titles = self.settings.catalog_mosaic_show_titles == true
self.default_sort = self.settings.catalog_sort or "recently_added" self.default_sort = self.settings.catalog_sort or "recently_added"
self.list_rows = self:computeListRows() self.list_rows = self:computeListRows()
self.on_device = {} self.on_device = {}
@@ -239,6 +282,14 @@ function BookOrbitCatalog:init()
end) end)
end end
function BookOrbitCatalog:downloadIconPath()
local source_dir = self.path or (self._manager and self._manager.path)
if source_dir then
local path = source_dir .. "/assets/" .. DOWNLOAD_ICON_FILE
if lfs.attributes(path, "mode") == "file" then return path end
end
end
-- Removes pre-versioning cache files ("<id>.jpg"). They were keyed by book id -- Removes pre-versioning cache files ("<id>.jpg"). They were keyed by book id
-- only, so after a library rescan reassigned ids they showed the wrong cover. -- only, so after a library rescan reassigned ids they showed the wrong cover.
function BookOrbitCatalog:cleanLegacyThumbnails() function BookOrbitCatalog:cleanLegacyThumbnails()
@@ -251,6 +302,29 @@ function BookOrbitCatalog:cleanLegacyThumbnails()
end end
end end
function BookOrbitCatalog:isBulkSelectionActive()
return self:bookMode() and self.bulk_selection_mode == true
end
function BookOrbitCatalog:titleBarSearchIcon()
if self:detailMode() then return nil end
return self:isBulkSelectionActive() and DOWNLOAD_ICON or "appbar.search"
end
function BookOrbitCatalog:titleBarSearchIconFile()
if not self:isBulkSelectionActive() then return nil end
return self:downloadIconPath()
end
function BookOrbitCatalog:titleBarSearchEnabled()
return not self:isBulkSelectionActive() or self:bulkSelectedCount() > 0
end
function BookOrbitCatalog:titleBarRefreshIcon()
if self:detailMode() then return nil end
return self:isBulkSelectionActive() and "close" or "cre.render.reload"
end
function BookOrbitCatalog:buildTitleBar(title, subtitle) function BookOrbitCatalog:buildTitleBar(title, subtitle)
return BookOrbitTitleBar:new{ return BookOrbitTitleBar:new{
width = Screen:getWidth(), width = Screen:getWidth(),
@@ -270,16 +344,21 @@ function BookOrbitCatalog:buildTitleBar(title, subtitle)
left_icon_tap_callback = function() self:onLeftButtonTap() end, left_icon_tap_callback = function() self:onLeftButtonTap() end,
left_icon_hold_callback = function() self:onLeftButtonHold() end, left_icon_hold_callback = function() self:onLeftButtonHold() end,
right_icon_size_ratio = 1, right_icon_size_ratio = 1,
close_callback = function() self:onClose() end, search_icon = self:titleBarSearchIcon(),
search_icon = "appbar.search", search_icon_file = self:titleBarSearchIconFile(),
search_icon_tap_callback = function() self:onSearchButtonTap() end, search_icon_tap_callback = function() self:onSearchButtonTap() end,
refresh_icon = "cre.render.reload", search_icon_allow_flash = self:titleBarSearchEnabled(),
search_icon_enabled = self:titleBarSearchEnabled(),
refresh_icon = self:titleBarRefreshIcon(),
refresh_icon_tap_callback = function() self:onRefreshButtonTap() end, refresh_icon_tap_callback = function() self:onRefreshButtonTap() end,
show_parent = self.show_parent or self, show_parent = self.show_parent or self,
} }
end end
function BookOrbitCatalog:resetTitleBar(title, subtitle) function BookOrbitCatalog:resetTitleBar(title, subtitle)
if self.bulkDecorateTitle then
title, subtitle = self:bulkDecorateTitle(title, subtitle)
end
self.custom_title_bar = self:buildTitleBar(title, subtitle) self.custom_title_bar = self:buildTitleBar(title, subtitle)
self.title_bar = self.custom_title_bar self.title_bar = self.custom_title_bar
if self.content_group then if self.content_group then
@@ -656,6 +735,9 @@ function BookOrbitCatalog:switchTo(title, item_table, context, push)
end end
self:updateReturnPath() self:updateReturnPath()
self.current_context = context self.current_context = context
if self.bulkHandleContextChange then
self:bulkHandleContextChange(context)
end
self:resetTitleBar(title, context.subtitle or "") self:resetTitleBar(title, context.subtitle or "")
self:switchItemTable(title, item_table, nil, nil, context.subtitle or "") self:switchItemTable(title, item_table, nil, nil, context.subtitle or "")
if context.kind == "books" then if context.kind == "books" then
@@ -1331,6 +1413,20 @@ function BookOrbitCatalog:setGrid(cols, rows)
end end
end end
function BookOrbitCatalog:setMosaicShowTitles(show_titles)
show_titles = show_titles == true
if self.mosaic_show_titles == show_titles then return end
self.mosaic_show_titles = show_titles
self:persistSetting("catalog_mosaic_show_titles", show_titles)
if self:bookMode() and self.view_mode == "mosaic" then
self:updateItems()
end
end
function BookOrbitCatalog:toggleMosaicTitles()
self:setMosaicShowTitles(not self.mosaic_show_titles)
end
function BookOrbitCatalog:showGridDialog() function BookOrbitCatalog:showGridDialog()
local dialog local dialog
local grid_label = _("%1 x %2") local grid_label = _("%1 x %2")
@@ -1419,6 +1515,56 @@ function BookOrbitCatalog:showFormatDialog()
UIManager:show(dialog) UIManager:show(dialog)
end end
function BookOrbitCatalog:showDownloadActions()
local context = self.current_context or {}
local dialog
local buttons = {}
local function addRow(button)
table.insert(buttons, { button })
end
if self:isBulkSelectionActive() and self:bulkSelectedCount() > 0 then
addRow({
text = _("Download selected"),
callback = function()
UIManager:close(dialog)
self:confirmBulkBooks(self:bulkSelectedBooks(), _("Selected books"), { clear_selection = true })
end,
})
end
if self:bookMode() and #(self.item_table or {}) > 0 then
addRow({
text = _("Download this page"),
callback = function()
UIManager:close(dialog)
self:confirmBulkCurrentPage()
end,
})
end
if self:bookMode() and (context.total or 0) > 0 then
addRow({
text = _("Download all in this list"),
callback = function()
UIManager:close(dialog)
self:confirmBulkAllMatching()
end,
})
end
addRow({
text = _("Settings"),
callback = function()
UIManager:close(dialog)
self:showBulkDownloadSettings()
end,
})
dialog = ButtonDialog:new{
title = _("Download"),
buttons = buttons,
}
UIManager:show(dialog)
end
function BookOrbitCatalog:showBookActions() function BookOrbitCatalog:showBookActions()
local context = self.current_context or {} local context = self.current_context or {}
local in_books = context.kind == "books" local in_books = context.kind == "books"
@@ -1427,6 +1573,7 @@ function BookOrbitCatalog:showBookActions()
local query = context.params or {} local query = context.params or {}
local view_label = self.view_mode == "list" and _("View: List") or _("View: Mosaic") local view_label = self.view_mode == "list" and _("View: List") or _("View: Mosaic")
local grid_label = T(_("%1 x %2"), self.grid_cols, self.grid_rows) local grid_label = T(_("%1 x %2"), self.grid_cols, self.grid_rows)
local titles_label = self.mosaic_show_titles and _("Titles: Shown") or _("Titles: Hidden")
local dialog local dialog
local buttons = { local buttons = {
{ {
@@ -1461,6 +1608,22 @@ function BookOrbitCatalog:showBookActions()
end end
if in_books then if in_books then
table.insert(buttons, {
{
text = _("Select books"),
callback = function()
UIManager:close(dialog)
self:bulkEnterSelectionMode()
end,
},
{
text = _("Download..."),
callback = function()
UIManager:close(dialog)
self:showDownloadActions()
end,
},
})
table.insert(buttons, { table.insert(buttons, {
{ {
text = T(_("Sort: %1"), self:sortLabel(query.sort)), text = T(_("Sort: %1"), self:sortLabel(query.sort)),
@@ -1514,6 +1677,16 @@ function BookOrbitCatalog:showBookActions()
end, end,
}, },
}) })
table.insert(buttons, {
{
text = titles_label,
enabled = self.view_mode == "mosaic",
callback = function()
UIManager:close(dialog)
self:toggleMosaicTitles()
end,
},
})
end end
if not self:dashboardMode() then if not self:dashboardMode() then
@@ -1528,6 +1701,16 @@ function BookOrbitCatalog:showBookActions()
}) })
end end
table.insert(buttons, {
{
text = _("Close BookOrbit"),
callback = function()
UIManager:close(dialog)
self:onCloseAllMenus()
end,
},
})
dialog = ButtonDialog:new{ dialog = ButtonDialog:new{
title = _("BookOrbit"), title = _("BookOrbit"),
buttons = buttons, buttons = buttons,
@@ -1535,88 +1718,237 @@ function BookOrbitCatalog:showBookActions()
UIManager:show(dialog) UIManager:show(dialog)
end end
function BookOrbitCatalog:updateVisibleBookSummary(detail)
if not detail or not detail.id then return end
local changed = false
local function update(book)
if not book or book.id ~= detail.id then return end
book.readStatus = detail.readStatus
book.progressPercentage = detail.progressPercentage
book.formats = detail.formats or book.formats
changed = true
end
for _, entry in ipairs(self.item_table or {}) do
update(entry.book)
end
local context = self.current_context or {}
for _, book in ipairs(context.books or {}) do
update(book)
end
local dashboard = context.dashboard or {}
for _, book in ipairs(dashboard.continueReading or {}) do
update(book)
end
for _, book in ipairs(dashboard.discover or {}) do
update(book)
end
if changed and (self:bookMode() or self:dashboardMode()) then
self:updateItems()
end
end
function BookOrbitCatalog:showBookActionSheet(detail, opts)
if not detail then return end
opts = opts or {}
self:refreshOnDevice()
local has_genres = detail.genres and #detail.genres > 0
local has_tags = detail.tags and #detail.tags > 0
local supported_files = opts.supported_files or self:supportedFiles(detail)
local include_details = opts.include_details ~= false
local include_quick_actions = opts.include_quick_actions ~= false
local allow_select = opts.allow_select == true
local read_path = self:detailReadPath(detail)
local undownloaded = 0
for _, file in ipairs(supported_files) do
if not self:onDeviceFilePath(file) then
undownloaded = undownloaded + 1
end
end
local show_download = #supported_files > 0 and (read_path == nil or undownloaded > 0)
local dialog
local buttons = {}
local function addRow(first, second)
if second then
table.insert(buttons, { first, second })
else
table.insert(buttons, { first })
end
end
local function closeThen(callback)
UIManager:close(dialog)
callback()
end
if include_details or allow_select then
local details_button = include_details and {
text = _("Details"),
callback = function()
closeThen(function()
self:showBookDetail(detail)
end)
end,
} or nil
local select_button
if allow_select then
local selected = self.bulkIsBookSelected and self:bulkIsBookSelected(detail)
select_button = {
text = selected and _("Unselect") or _("Select"),
callback = function()
closeThen(function()
if self:isBulkSelectionActive() then
self:bulkToggleBook(detail)
else
self:bulkEnterSelectionMode({ kind = "book", book = detail })
end
end)
end,
}
end
addRow(details_button or select_button, details_button and select_button or nil)
end
if include_quick_actions then
local read_button = read_path and {
text = _("Read"),
callback = function()
closeThen(function()
self:openDownloadedFile(read_path)
end)
end,
} or nil
local download_button = {
text = _("Download"),
enabled = show_download,
callback = function()
closeThen(function()
if #supported_files == 1 then
self:downloadDefaultFile(detail, supported_files[1])
else
self:showFileChoices(detail)
end
end)
end,
}
if read_button and show_download then
addRow(read_button, download_button)
elseif read_button then
addRow(read_button)
else
addRow(download_button)
end
end
addRow({
text = _("Set read status"),
callback = function()
closeThen(function()
self:showSetStatusDialog(detail)
end)
end,
}, {
text = _("Set rating"),
callback = function()
closeThen(function()
self:showSetRatingDialog(detail)
end)
end,
})
addRow({
text = _("Description"),
enabled = cleanDescriptionText(detail.description) ~= nil,
callback = function()
closeThen(function()
self:showFullText(_("Description"), detail.description)
end)
end,
})
addRow({
text = _("Genres"),
enabled = has_genres,
callback = function()
closeThen(function()
self:showFullList(_("Genres"), detail.genres)
end)
end,
}, {
text = _("Tags"),
enabled = has_tags,
callback = function()
closeThen(function()
self:showFullList(_("Tags"), detail.tags)
end)
end,
})
if opts.include_page_actions == true then
addRow({
text = _("Refresh"),
callback = function()
closeThen(function()
self:refreshCurrent()
end)
end,
}, {
text = _("Dashboard"),
callback = function()
closeThen(function()
self:goDashboard()
end)
end,
})
addRow({
text = _("Close BookOrbit"),
callback = function()
closeThen(function()
self:onCloseAllMenus()
end)
end,
})
end
dialog = ButtonDialog:new{
title = detail.title or _("Book"),
buttons = buttons,
}
UIManager:show(dialog)
end
function BookOrbitCatalog:showDetailActions() function BookOrbitCatalog:showDetailActions()
local context = self.current_context or {} local context = self.current_context or {}
local detail = context.detail local detail = context.detail
if not detail then return end if not detail then return end
local has_genres = detail.genres and #detail.genres > 0 self:showBookActionSheet(detail, {
local has_tags = detail.tags and #detail.tags > 0 supported_files = context.supported_files,
local supported_files = context.supported_files or self:supportedFiles(detail) include_details = false,
local dialog include_quick_actions = false,
dialog = ButtonDialog:new{ include_page_actions = true,
title = detail.title or _("Book"), })
buttons = { end
{
{ function BookOrbitCatalog:showBookActionSheetForEntry(item)
text = _("Set read status"), local book_id = item and (item.book_id or (item.book and item.book.id))
callback = function() if not book_id then return end
UIManager:close(dialog) self:runConnected(function()
self:showSetStatusDialog(detail) local detail, err = self:fetch(_("Loading book..."), function()
end, return self.client:catalogBook(book_id)
}, end)
{ if not detail then
text = _("Set rating"), if err ~= "cancelled" then
callback = function() self:showRetry(err, function()
UIManager:close(dialog) self:showBookActionSheetForEntry(item)
self:showSetRatingDialog(detail) end)
end, end
}, return
}, end
{ self:showBookActionSheet(detail, {
{ allow_select = item.kind == "book" and self:bookMode(),
text = _("Download options"), })
enabled = #supported_files > 0, end)
callback = function()
UIManager:close(dialog)
self:showDownloadOptions(detail)
end,
},
{
text = _("Description"),
enabled = cleanDescriptionText(detail.description) ~= nil,
callback = function()
UIManager:close(dialog)
self:showFullText(_("Description"), detail.description)
end,
},
},
{
{
text = _("Genres"),
enabled = has_genres,
callback = function()
UIManager:close(dialog)
self:showFullList(_("Genres"), detail.genres)
end,
},
{
text = _("Tags"),
enabled = has_tags,
callback = function()
UIManager:close(dialog)
self:showFullList(_("Tags"), detail.tags)
end,
},
},
{
{
text = _("Refresh"),
callback = function()
UIManager:close(dialog)
self:refreshCurrent()
end,
},
{
text = _("Dashboard"),
callback = function()
UIManager:close(dialog)
self:goDashboard()
end,
},
},
},
}
UIManager:show(dialog)
end end
function BookOrbitCatalog:showSetStatusDialog(detail) function BookOrbitCatalog:showSetStatusDialog(detail)
@@ -1694,6 +2026,7 @@ function BookOrbitCatalog:applyReadStatus(detail, status)
end end
detail.readStatus = body.readStatus or status detail.readStatus = body.readStatus or status
self:markStackDirty() self:markStackDirty()
self:updateVisibleBookSummary(detail)
self:refreshDetailView() self:refreshDetailView()
UIManager:show(InfoMessage:new{ text = _("Read status updated"), timeout = 1 }) UIManager:show(InfoMessage:new{ text = _("Read status updated"), timeout = 1 })
end) end)
@@ -1710,6 +2043,7 @@ function BookOrbitCatalog:applyRating(detail, rating)
end end
detail.rating = body.rating detail.rating = body.rating
self:markStackDirty() self:markStackDirty()
self:updateVisibleBookSummary(detail)
self:refreshDetailView() self:refreshDetailView()
UIManager:show(InfoMessage:new{ text = _("Rating updated"), timeout = 1 }) UIManager:show(InfoMessage:new{ text = _("Rating updated"), timeout = 1 })
end) end)
@@ -1725,7 +2059,9 @@ function BookOrbitCatalog:updateLeftIcon()
end end
function BookOrbitCatalog:onLeftButtonTap() function BookOrbitCatalog:onLeftButtonTap()
if self:dashboardMode() and self.show_dashboard_menu then if self:isBulkSelectionActive() then
self:showBulkSelectionActions()
elseif self:dashboardMode() and self.show_dashboard_menu then
self.show_dashboard_menu(self) self.show_dashboard_menu(self)
elseif self:detailMode() then elseif self:detailMode() then
self:showDetailActions() self:showDetailActions()
@@ -1736,6 +2072,13 @@ function BookOrbitCatalog:onLeftButtonTap()
end end
function BookOrbitCatalog:onSearchButtonTap() function BookOrbitCatalog:onSearchButtonTap()
if self:isBulkSelectionActive() then
if self:bulkSelectedCount() > 0 then
self:confirmBulkBooks(self:bulkSelectedBooks(), _("Selected books"), { clear_selection = true })
end
return true
end
local context = self.current_context or {} local context = self.current_context or {}
if context.kind == "books" then if context.kind == "books" then
self:promptSearch(self:scopeParams(context.params or {}), context.title) self:promptSearch(self:scopeParams(context.params or {}), context.title)
@@ -1746,6 +2089,11 @@ function BookOrbitCatalog:onSearchButtonTap()
end end
function BookOrbitCatalog:onRefreshButtonTap() function BookOrbitCatalog:onRefreshButtonTap()
if self:isBulkSelectionActive() then
self:bulkExitSelectionMode()
return true
end
self:refreshCurrent() self:refreshCurrent()
return true return true
end end
@@ -2756,13 +3104,25 @@ function BookOrbitCatalog:onMenuSelect(item)
elseif item.kind == "books" then elseif item.kind == "books" then
self:loadBooks(item.params or {}, item.list_title or item.text) self:loadBooks(item.params or {}, item.list_title or item.text)
elseif item.kind == "book" then elseif item.kind == "book" then
self:loadBookDetail(item.book_id) if self:isBulkSelectionActive() then
self:bulkToggleEntry(item)
else
self:loadBookDetail(item.book_id)
end
elseif item.kind == "sort" then elseif item.kind == "sort" then
self:showSortDialog(item) self:showSortDialog(item)
end end
return true return true
end end
function BookOrbitCatalog:onMenuHoldSelect(item)
if item.kind == "book" or item.kind == "dashboard-book" then
self:showBookActionSheetForEntry(item)
return true
end
return self:onMenuSelect(item)
end
-- Fetches a fresh set of random Discover books and swaps them into the current -- Fetches a fresh set of random Discover books and swaps them into the current
-- dashboard without reloading the rest of the page. -- dashboard without reloading the rest of the page.
function BookOrbitCatalog:rerollDiscover() function BookOrbitCatalog:rerollDiscover()
@@ -2794,6 +3154,9 @@ function BookOrbitCatalog:onReturn()
dirty = self.current_context.dirty == true dirty = self.current_context.dirty == true
self.current_context.dirty = nil self.current_context.dirty = nil
self:updateReturnPath() self:updateReturnPath()
if self.bulkHandleContextChange then
self:bulkHandleContextChange(self.current_context)
end
self:switchItemTable(previous.title, previous.item_table, nil, nil, previous.subtitle or "") self:switchItemTable(previous.title, previous.item_table, nil, nil, previous.subtitle or "")
if self.current_context.kind == "books" then if self.current_context.kind == "books" then
self:scheduleThumbnailDownloads(self.current_context.books or {}) self:scheduleThumbnailDownloads(self.current_context.books or {})
@@ -2805,6 +3168,9 @@ function BookOrbitCatalog:onReturn()
else else
self.item_table, self.current_context = self:dashboardRoot() self.item_table, self.current_context = self:dashboardRoot()
self:updateReturnPath() self:updateReturnPath()
if self.bulkHandleContextChange then
self:bulkHandleContextChange(self.current_context)
end
self:switchItemTable(self.current_context.title or self.title, self.item_table, nil, nil, self.current_context.subtitle or "") self:switchItemTable(self.current_context.title or self.title, self.item_table, nil, nil, self.current_context.subtitle or "")
if self.current_context.kind == "dashboard" then if self.current_context.kind == "dashboard" then
self:scheduleThumbnailDownloads(self:dashboardBooks(self.current_context.dashboard)) self:scheduleThumbnailDownloads(self:dashboardBooks(self.current_context.dashboard))
@@ -2825,6 +3191,15 @@ end
function BookOrbitCatalog:onCloseWidget() function BookOrbitCatalog:onCloseWidget()
self:cancelThumbnailJobs() self:cancelThumbnailJobs()
if self.bulk_ctx then
local ctx = self.bulk_ctx
self.bulk_running = false
self.bulk_ctx = nil
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
ctx.progress_dialog = nil
end
end
return Menu.onCloseWidget(self) return Menu.onCloseWidget(self)
end end
@@ -0,0 +1,813 @@
--[[--
Bulk download manager mixin for the BookOrbit catalog browser.
Runs explicit foreground queues for selected books, current pages and all books
matching the current list. The queue deliberately reuses the catalog's existing
download and local sync-link primitives.
]]
local BD = require("ui/bidi")
local ButtonDialog = require("ui/widget/buttondialog")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local T = require("ffi/util").template
local _ = require("gettext")
local CatalogUtil = require("bookorbit_catalog_util")
local cloneParams = CatalogUtil.cloneParams
local formatBytes = CatalogUtil.formatBytes
local isSupportedFormat = CatalogUtil.isSupportedFormat
local safeFilenameBase = CatalogUtil.safeFilenameBase
local shortText = CatalogUtil.shortText
local STEP_DELAY = 0.15
local PROGRESS_THROTTLE = 1
local FAILED_TITLES_LIMIT = 8
local FORMAT_PRESETS = {
{
id = "epub",
label = _("EPUB first"),
order = { "epub", "kepub", "azw3", "mobi", "fb2", "txt", "pdf", "djvu", "cbz", "cbr" },
},
{
id = "pdf",
label = _("PDF first"),
order = { "pdf", "djvu", "epub", "kepub", "azw3", "mobi", "fb2", "txt", "cbz", "cbr" },
},
{
id = "comics",
label = _("Comics first"),
order = { "cbz", "cbr", "pdf", "djvu", "epub", "kepub", "azw3", "mobi", "fb2", "txt" },
},
}
local EXISTING_POLICIES = {
{ id = "skip", label = _("Skip existing") },
{ id = "overwrite", label = _("Overwrite existing") },
}
local function presetById(id)
for _, preset in ipairs(FORMAT_PRESETS) do
if preset.id == id then return preset end
end
return FORMAT_PRESETS[1]
end
local function policyById(id)
for _, policy in ipairs(EXISTING_POLICIES) do
if policy.id == id then return policy end
end
return EXISTING_POLICIES[1]
end
local function bookTitle(book)
return book and book.title or _("Untitled")
end
local function sortedParamKey(params)
local keys = {}
for key in pairs(params or {}) do
if key ~= "page" and key ~= "size" then
table.insert(keys, key)
end
end
table.sort(keys)
local parts = {}
for _, key in ipairs(keys) do
table.insert(parts, tostring(key) .. "=" .. tostring(params[key]))
end
return table.concat(parts, "&")
end
local function dedupeBooks(books)
local seen = {}
local result = {}
for _, book in ipairs(books or {}) do
local id = book and book.id
if id and not seen[id] then
seen[id] = true
table.insert(result, book)
end
end
return result
end
local CatalogBulkDownload = {}
function CatalogBulkDownload.install(Catalog)
function Catalog:initBulkDownloadState()
self.bulk_selection_mode = false
self.bulk_selection_scope_key = nil
self.bulk_selected_books = {}
self.bulk_selected_order = {}
self.bulk_running = false
self.bulk_ctx = nil
end
function Catalog:bulkFormatPreset()
return presetById(self.settings.catalog_bulk_format_preset)
end
function Catalog:bulkExistingPolicy()
return policyById(self.settings.catalog_bulk_existing_policy)
end
function Catalog:bulkListParams(context)
local params = cloneParams((context or {}).params or {})
params.page = nil
params.size = nil
return params
end
function Catalog:bulkListScopeKey(context)
if not context or context.kind ~= "books" then return nil end
return tostring(context.title or "") .. "|" .. sortedParamKey(context.params or {})
end
function Catalog:bulkHandleContextChange(context)
local key = self:bulkListScopeKey(context)
if self.bulk_selection_scope_key and self.bulk_selection_scope_key ~= key then
self:bulkClearSelection(false)
elseif self.bulk_selection_mode and not key then
self:bulkClearSelection(false)
end
end
function Catalog:bulkDecorateTitle(title, subtitle)
if not self.bulk_selection_mode then return title, subtitle end
local count = self:bulkSelectedCount()
local selected_text = count == 1 and _("1 selected") or T(_("%1 selected"), count)
if subtitle and subtitle ~= "" then
subtitle = selected_text .. " - " .. subtitle
else
subtitle = selected_text
end
return _("Select books"), subtitle
end
function Catalog:bulkSelectedCount()
local count = 0
for _ in pairs(self.bulk_selected_books or {}) do
count = count + 1
end
return count
end
function Catalog:bulkIsBookSelected(book)
return book and book.id and self.bulk_selected_books and self.bulk_selected_books[book.id] ~= nil
end
function Catalog:bulkEnterSelectionMode(entry)
if not self:bookMode() then return end
self.bulk_selection_mode = true
self.bulk_selection_scope_key = self:bulkListScopeKey(self.current_context)
if entry and entry.book then
self:bulkSelectBook(entry.book)
end
self:updateItems()
end
function Catalog:bulkClearSelectedBooks(redraw)
self.bulk_selected_books = {}
self.bulk_selected_order = {}
if redraw then self:updateItems() end
end
function Catalog:bulkClearSelection(redraw)
self.bulk_selection_mode = false
self.bulk_selection_scope_key = nil
self:bulkClearSelectedBooks(redraw)
end
function Catalog:bulkExitSelectionMode()
self:bulkClearSelection(true)
end
function Catalog:bulkSelectBook(book)
if not book or not book.id then return end
if not self.bulk_selected_books[book.id] then
table.insert(self.bulk_selected_order, book.id)
end
self.bulk_selected_books[book.id] = book
end
function Catalog:bulkToggleBook(book)
if not book or not book.id then return end
if self.bulk_selected_books[book.id] then
self.bulk_selected_books[book.id] = nil
else
self:bulkSelectBook(book)
end
self:updateItems()
end
function Catalog:bulkToggleEntry(entry)
if not self:bookMode() or not entry or entry.kind ~= "book" then return false end
if not self.bulk_selection_mode then
self:bulkEnterSelectionMode(entry)
else
self:bulkToggleBook(entry.book)
end
return true
end
function Catalog:bulkSelectedBooks()
local books = {}
local retained_order = {}
for _, book_id in ipairs(self.bulk_selected_order or {}) do
local book = self.bulk_selected_books[book_id]
if book then
table.insert(books, book)
table.insert(retained_order, book_id)
end
end
self.bulk_selected_order = retained_order
return books
end
function Catalog:bulkCurrentPageBooks()
local books = {}
for _, entry in ipairs(self.item_table or {}) do
if entry.kind == "book" and entry.book then
table.insert(books, entry.book)
end
end
return books
end
function Catalog:bulkSelectCurrentPage()
if not self:bookMode() then return end
self.bulk_selection_mode = true
self.bulk_selection_scope_key = self:bulkListScopeKey(self.current_context)
for _, book in ipairs(self:bulkCurrentPageBooks()) do
self:bulkSelectBook(book)
end
self:updateItems()
end
function Catalog:showBulkSelectionActions()
local dialog
dialog = ButtonDialog:new{
title = T(_("Selected books: %1"), self:bulkSelectedCount()),
buttons = {
{
{
text = _("Download selected"),
enabled = self:bulkSelectedCount() > 0,
callback = function()
UIManager:close(dialog)
self:confirmBulkBooks(
self:bulkSelectedBooks(), _("Selected books"), { clear_selection = true })
end,
},
},
{
{
text = _("Select page"),
callback = function()
UIManager:close(dialog)
self:bulkSelectCurrentPage()
end,
},
{
text = _("Clear"),
enabled = self:bulkSelectedCount() > 0,
callback = function()
UIManager:close(dialog)
self:bulkClearSelectedBooks(true)
end,
},
},
{
{
text = _("Exit selection"),
callback = function()
UIManager:close(dialog)
self:bulkExitSelectionMode()
end,
},
},
{
{
text = _("Close BookOrbit"),
callback = function()
UIManager:close(dialog)
self:onCloseAllMenus()
end,
},
},
},
}
UIManager:show(dialog)
end
function Catalog:showBulkDownloadSettings()
local dialog
local preset = self:bulkFormatPreset()
local policy = self:bulkExistingPolicy()
dialog = ButtonDialog:new{
title = _("Bulk download settings"),
buttons = {
{
{
text = T(_("Format: %1"), preset.label),
callback = function()
UIManager:close(dialog)
self:showBulkFormatPresetDialog()
end,
},
},
{
{
text = T(_("Existing files: %1"), policy.label),
callback = function()
UIManager:close(dialog)
self:showBulkExistingPolicyDialog()
end,
},
},
},
}
UIManager:show(dialog)
end
function Catalog:showBulkFormatPresetDialog()
local current = self:bulkFormatPreset().id
local dialog
local buttons = {}
for _, preset in ipairs(FORMAT_PRESETS) do
table.insert(buttons, {
{
text = preset.label .. (current == preset.id and " *" or ""),
callback = function()
UIManager:close(dialog)
self:persistSetting("catalog_bulk_format_preset", preset.id)
end,
},
})
end
dialog = ButtonDialog:new{ title = _("Bulk format preference"), buttons = buttons }
UIManager:show(dialog)
end
function Catalog:showBulkExistingPolicyDialog()
local current = self:bulkExistingPolicy().id
local dialog
local buttons = {}
for _, policy in ipairs(EXISTING_POLICIES) do
table.insert(buttons, {
{
text = policy.label .. (current == policy.id and " *" or ""),
callback = function()
UIManager:close(dialog)
self:persistSetting("catalog_bulk_existing_policy", policy.id)
end,
},
})
end
dialog = ButtonDialog:new{ title = _("Existing files"), buttons = buttons }
UIManager:show(dialog)
end
function Catalog:bulkSupportedFormatHint(book)
if not book or not book.formats or #book.formats == 0 then return true end
for _, format in ipairs(book.formats) do
if isSupportedFormat(format) then return true end
end
return false
end
function Catalog:bulkCountKnownSkips(books)
local counts = { on_device = 0, unsupported = 0 }
for _, book in ipairs(books or {}) do
if self:isOnDevice(book) then
counts.on_device = counts.on_device + 1
elseif not self:bulkSupportedFormatHint(book) then
counts.unsupported = counts.unsupported + 1
end
end
return counts
end
function Catalog:bulkConfirmationTitle(source)
local total = source.total or #(source.books or {})
local lines = {
T(_("Download %1 books?"), total),
T(_("Scope: %1"), source.label or _("Books")),
T(_("Folder: %1"), BD.dirpath(self:getCurrentDownloadDir())),
T(_("Format: %1"), self:bulkFormatPreset().label),
T(_("Existing files: %1"), self:bulkExistingPolicy().label),
}
if source.known_skips then
if source.known_skips.on_device > 0 then
table.insert(lines, T(_("Already on device: %1"), source.known_skips.on_device))
end
if source.known_skips.unsupported > 0 then
table.insert(lines, T(_("No supported format: %1"), source.known_skips.unsupported))
end
end
return table.concat(lines, "\n")
end
function Catalog:confirmBulkSource(source)
if self.bulk_running then
UIManager:show(InfoMessage:new{ text = _("A bulk download is already running."), timeout = 3 })
return
end
if (source.total or #(source.books or {})) == 0 then
UIManager:show(InfoMessage:new{ text = _("No books to download."), timeout = 2 })
return
end
local dialog
dialog = ButtonDialog:new{
title = self:bulkConfirmationTitle(source),
buttons = {
{
{
text = _("Download"),
callback = function()
UIManager:close(dialog)
self:startBulkSource(source)
end,
},
},
{
{
text = _("Cancel"),
callback = function()
UIManager:close(dialog)
end,
},
},
},
}
UIManager:show(dialog)
end
function Catalog:confirmBulkBooks(books, label, opts)
books = dedupeBooks(books)
self:refreshOnDevice()
self:confirmBulkSource({
label = label,
total = #books,
books = books,
known_skips = self:bulkCountKnownSkips(books),
clear_selection = opts and opts.clear_selection == true,
resolve = function()
return books
end,
})
end
function Catalog:confirmBulkCurrentPage()
self:confirmBulkBooks(self:bulkCurrentPageBooks(), _("Current page"))
end
function Catalog:confirmBulkAllMatching()
if not self:bookMode() then return end
local context = self.current_context or {}
local total = context.total or #(context.books or {})
local params = self:bulkListParams(context)
self:confirmBulkSource({
label = context.title or _("Current list"),
total = total,
resolve = function(ctx)
return self:bulkLoadAllMatchingBooks(params, total, ctx)
end,
})
end
function Catalog:bulkLoadAllMatchingBooks(params, expected_total, ctx)
local MAX_PAGES = 200
local books = {}
local page = 1
local has_next = true
while has_next and page <= MAX_PAGES do
if ctx.cancel_requested then return nil, "cancelled" end
local query = cloneParams(params)
query.page = page
query.size = 100
self:bulkSetProgress(ctx, T(_("Loading list page %1..."), page), true)
local body, err = self.client:catalogBooks(query)
if not body then return nil, err end
for _, book in ipairs(body.items or {}) do
table.insert(books, book)
end
has_next = body.hasNext == true and #books < (expected_total or body.total or #books)
page = page + 1
end
return dedupeBooks(books)
end
function Catalog:startBulkSource(source)
if self.bulk_running then
UIManager:show(InfoMessage:new{ text = _("A bulk download is already running."), timeout = 3 })
return
end
local ctx = {
label = source.label or _("Books"),
total = source.total or 0,
books = {},
index = 0,
counts = {
downloaded = 0,
linked = 0,
skipped_on_device = 0,
skipped_unsupported = 0,
skipped_existing = 0,
failed = 0,
},
failed_books = {},
failed_titles = {},
cancel_requested = false,
cancelled = false,
progress_dialog = nil,
progress_shown_text = nil,
progress_shown_at = 0,
}
self.bulk_running = true
self.bulk_ctx = ctx
if source.clear_selection then
self:bulkClearSelection(true)
end
self:bulkSetProgress(ctx, _("Preparing bulk download..."), true)
UIManager:scheduleIn(STEP_DELAY, function()
local ok, result, resolve_err = pcall(function()
return source.resolve(ctx)
end)
if not ok then
logger.warn("BookOrbit: bulk download preparation failed", result)
self:bulkAbort(ctx, _("Could not prepare bulk download."))
return
end
if not result then
logger.warn("BookOrbit: bulk download list request failed", resolve_err)
self:bulkAbort(ctx, _("Could not load the selected books."))
return
end
ctx.books = dedupeBooks(result)
ctx.total = #ctx.books
if ctx.total == 0 then
self:bulkAbort(ctx, _("No books to download."))
return
end
self:bulkQueueStep(ctx)
end)
end
function Catalog:bulkAbort(ctx, message)
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
ctx.progress_dialog = nil
end
self.bulk_running = false
self.bulk_ctx = nil
UIManager:show(InfoMessage:new{ text = message, timeout = 4 })
end
function Catalog:bulkSetProgress(ctx, text, force)
if not ctx then return end
if not force then
if text == ctx.progress_shown_text then return end
if ctx.progress_dialog and os.time() - (ctx.progress_shown_at or 0) < PROGRESS_THROTTLE then
return
end
end
ctx.progress_shown_text = text
ctx.progress_shown_at = os.time()
if ctx.progress_dialog then
ctx.progress_dialog:setTitle(text)
else
ctx.progress_dialog = ButtonDialog:new{
title = text,
buttons = {
{
{
text = _("Cancel after current file"),
callback = function()
ctx.cancel_requested = true
self:bulkSetProgress(ctx, _("Stopping after current file..."), true)
end,
},
},
},
}
UIManager:show(ctx.progress_dialog)
end
UIManager:forceRePaint()
end
function Catalog:bulkQueueStep(ctx)
if ctx ~= self.bulk_ctx then return end
if ctx.cancel_requested then
ctx.cancelled = true
self:bulkFinish(ctx)
return
end
local book = ctx.books[ctx.index + 1]
if not book then
self:bulkFinish(ctx)
return
end
ctx.index = ctx.index + 1
local ok, err = pcall(function()
self:bulkProcessBook(ctx, book)
end)
if not ok then
logger.warn("BookOrbit: bulk download item failed", err)
self:bulkRecordFailure(ctx, book)
end
UIManager:scheduleIn(STEP_DELAY, function()
self:bulkQueueStep(ctx)
end)
end
function Catalog:bulkProcessBook(ctx, book)
if self:isOnDevice(book) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
return
end
if not self:bulkSupportedFormatHint(book) then
ctx.counts.skipped_unsupported = ctx.counts.skipped_unsupported + 1
return
end
self:bulkSetProgress(ctx, T(_("Preparing %1/%2..."), ctx.index, ctx.total), true)
local detail, err = self.client:catalogBook(book.id)
if not detail then
logger.warn("BookOrbit: bulk detail fetch failed", book.id, err)
self:bulkRecordFailure(ctx, book)
return
end
if self:isOnDevice(detail) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
return
end
local file = self:bulkChooseFile(detail)
if not file then
ctx.counts.skipped_unsupported = ctx.counts.skipped_unsupported + 1
return
end
local filename = safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
local local_path = self:getLocalDownloadPath(filename, filetype)
if lfs.attributes(local_path) and self:bulkExistingPolicy().id == "skip" then
ctx.counts.skipped_existing = ctx.counts.skipped_existing + 1
return
end
local ok, linked = self:bulkDownloadFile(ctx, detail, file, local_path)
if not ok then
self:bulkRecordFailure(ctx, book)
return
end
ctx.counts.downloaded = ctx.counts.downloaded + 1
if linked then ctx.counts.linked = ctx.counts.linked + 1 end
end
function Catalog:bulkChooseFile(detail)
local files = self:supportedFiles(detail)
if #files == 0 then return nil end
for _, format in ipairs(self:bulkFormatPreset().order) do
local wanted = string.lower(format)
for _, file in ipairs(files) do
local file_format = string.lower(file.format or "")
if file_format == wanted and not self:onDeviceFilePath(file) then
return file
end
end
end
for _, file in ipairs(files) do
if not self:onDeviceFilePath(file) then
return file
end
end
return nil
end
function Catalog:bulkDownloadFile(ctx, detail, file, local_path)
local total = file.sizeBytes
local last_bucket = -1
local function on_progress(received)
local text, bucket
if total and total > 0 then
local pct = math.min(100, math.floor(received / total * 100))
bucket = math.floor(pct / 5)
if bucket == last_bucket then return end
text = T(_("Downloading %1/%2... %3"), ctx.index, ctx.total, pct .. "%")
else
bucket = math.floor(received / (256 * 1024))
if bucket == last_bucket then return end
text = T(_("Downloading %1/%2... %3"), ctx.index, ctx.total, formatBytes(received))
end
last_bucket = bucket
self:bulkSetProgress(ctx, text, true)
end
self:bulkSetProgress(ctx, T(_("Downloading %1/%2..."), ctx.index, ctx.total), true)
local ok, err = self.client:downloadCatalogFile(file.id, local_path, on_progress)
if not ok then
logger.warn("BookOrbit: bulk file download failed", detail.id, file.id, err)
return false, false
end
local linked = self:linkDownloadedFile(local_path)
if linked then
self:refreshOnDevice()
if self.markStackDirty then self:markStackDirty() end
end
return true, linked
end
function Catalog:bulkRecordFailure(ctx, book)
ctx.counts.failed = ctx.counts.failed + 1
table.insert(ctx.failed_books, book)
if #ctx.failed_titles < FAILED_TITLES_LIMIT then
table.insert(ctx.failed_titles, shortText(bookTitle(book), 60))
end
end
function Catalog:bulkFinish(ctx)
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
ctx.progress_dialog = nil
end
self.bulk_running = false
self.bulk_ctx = nil
self:refreshOnDevice()
if self.markStackDirty then self:markStackDirty() end
if self.updateItems and (self:bookMode() or self:dashboardMode()) then
self:updateItems()
end
local counts = ctx.counts
local lines = {
ctx.cancelled and _("Bulk download stopped.") or _("Bulk download complete."),
T(_("Downloaded: %1"), counts.downloaded),
T(_("Linked: %1"), counts.linked),
T(_("Skipped on device: %1"), counts.skipped_on_device),
T(_("Skipped existing: %1"), counts.skipped_existing),
T(_("No supported format: %1"), counts.skipped_unsupported),
T(_("Failed: %1"), counts.failed),
}
if #ctx.failed_titles > 0 then
table.insert(lines, "")
table.insert(lines, _("Failed books:"))
for _, title in ipairs(ctx.failed_titles) do
table.insert(lines, title)
end
if #ctx.failed_books > #ctx.failed_titles then
table.insert(lines, T(_("+%1 more"), #ctx.failed_books - #ctx.failed_titles))
end
end
local dialog
local buttons = {}
if #ctx.failed_books > 0 then
table.insert(buttons, {
{
text = _("Retry failed"),
callback = function()
UIManager:close(dialog)
self:confirmBulkBooks(ctx.failed_books, _("Failed downloads"))
end,
},
})
end
table.insert(buttons, {
{
text = _("View On device"),
callback = function()
UIManager:close(dialog)
self:loadOnDevice()
end,
},
{
text = _("Close"),
callback = function()
UIManager:close(dialog)
end,
},
})
dialog = ButtonDialog:new{
title = table.concat(lines, "\n"),
buttons = buttons,
}
UIManager:show(dialog)
end
end
return CatalogBulkDownload
@@ -263,8 +263,12 @@ function CatalogDownload.install(Catalog)
if linked then if linked then
self:refreshOnDevice() self:refreshOnDevice()
if self.markStackDirty then self:markStackDirty() end if self.markStackDirty then self:markStackDirty() end
local on_catalog_page = (self.bookMode and self:bookMode())
or (self.dashboardMode and self:dashboardMode())
if self.detailMode and self:detailMode() then if self.detailMode and self:detailMode() then
self:refreshDetailView() self:refreshDetailView()
elseif self.updateItems and on_catalog_page then
self:updateItems()
end end
end end
self:showDownloadedDialog(local_path, linked) self:showDownloadedDialog(local_path, linked)
@@ -92,7 +92,7 @@ CatalogUtil.SETTABLE_READ_STATUSES = {
CatalogUtil.COMMON_FORMATS = { "epub", "pdf", "cbz", "cbr", "mobi", "azw3", "fb2", "djvu", "txt" } CatalogUtil.COMMON_FORMATS = { "epub", "pdf", "cbz", "cbr", "mobi", "azw3", "fb2", "djvu", "txt" }
CatalogUtil.GRID_PRESETS = { CatalogUtil.GRID_PRESETS = {
{ 2, 2 }, { 2, 3 }, { 3, 3 }, { 3, 4 }, { 4, 4 }, { 4, 5 }, { 3, 3 }, { 4, 3 }, { 4, 4 }, { 5, 4 },
} }
function CatalogUtil.isAuthError(err) function CatalogUtil.isAuthError(err)
@@ -38,6 +38,8 @@ local firstAuthor = CatalogUtil.firstAuthor
local PROGRESS_BAR_HEIGHT = Screen:scaleBySize(3) local PROGRESS_BAR_HEIGHT = Screen:scaleBySize(3)
local SCALE_BY_SIZE = Screen:scaleBySize(1000000) * (1 / 1000000) local SCALE_BY_SIZE = Screen:scaleBySize(1000000) * (1 / 1000000)
local SELECTED_BACKGROUND = Blitbuffer.COLOR_LIGHT_GRAY
local SELECTED_TEXT = Blitbuffer.COLOR_DARK_GRAY
local READ_STATUS_BADGE_ICONS = { local READ_STATUS_BADGE_ICONS = {
want_to_read = "bookmark", want_to_read = "bookmark",
reading = "dogear.reading", reading = "dogear.reading",
@@ -66,6 +68,22 @@ local function rowFontSize(nominal, max_size, row_h)
return math.max(8, size) return math.max(8, size)
end end
local function longestLineLength(text)
local longest = 0
for line in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
longest = math.max(longest, #line)
end
return math.max(1, longest)
end
local function mosaicLabelFontSize(text, width, height)
local longest = longestLineLength(text)
local height_size = math.floor(height * 0.46 / SCALE_BY_SIZE)
local width_size = math.floor(width / math.max(8, math.min(longest, 16)) * 2.05 / SCALE_BY_SIZE)
local card_size = math.floor(width * (1 / 9.5) / SCALE_BY_SIZE)
return math.max(13, math.min(20, height_size, width_size, card_size))
end
-- A slim borderless progress bar, e-ink friendly (no animation): a light-gray -- A slim borderless progress bar, e-ink friendly (no animation): a light-gray
-- track with a solid black fill, used to visualize reading progress on cards. -- track with a solid black fill, used to visualize reading progress on cards.
function CatalogWidgets.buildProgressBar(percentage, width) function CatalogWidgets.buildProgressBar(percentage, width)
@@ -174,6 +192,58 @@ function CatalogWidgets.buildReadStatusBadge(book, max_width)
} }
end end
function CatalogWidgets.buildSelectionBadge(max_width)
local size = math.max(Screen:scaleBySize(16), math.min(max_width, Screen:scaleBySize(26)))
return IconWidget:new{
icon = "check",
width = size,
height = size,
}
end
function CatalogWidgets.buildDownloadedBadge(max_width)
local size = math.max(Screen:scaleBySize(14), math.min(max_width, Screen:scaleBySize(24)))
return IconWidget:new{
icon = "appbar.filebrowser",
width = size,
height = size,
}
end
function CatalogWidgets.buildCoverWithStateBadges(book, width, height, path, state, downloaded, selected)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state)
if not downloaded and not selected then return cover end
local group = OverlapGroup:new{
dimen = Geom:new{ w = width, h = height },
allow_mirroring = false,
cover,
}
if downloaded then
local badge = CatalogWidgets.buildDownloadedBadge(math.floor(math.min(width, height) * 0.18))
badge.overlap_align = "left"
table.insert(group, badge)
end
if selected then
local badge = CatalogWidgets.buildSelectionBadge(math.floor(math.min(width, height) * 0.18))
badge.overlap_align = "right"
table.insert(group, badge)
end
return group
end
local function selectedTextColor(selected)
return selected and SELECTED_TEXT or nil
end
local function selectedBackground(selected)
return selected and SELECTED_BACKGROUND or Blitbuffer.COLOR_WHITE
end
local function selectedTextBgColor(selected)
return selected and SELECTED_BACKGROUND or nil
end
local MosaicItem = InputContainer:extend{ local MosaicItem = InputContainer:extend{
entry = nil, entry = nil,
dimen = nil, dimen = nil,
@@ -198,35 +268,58 @@ function MosaicItem:init()
} }
local book = self.entry.book local book = self.entry.book
local show_label = self.menu.mosaic_show_titles == true
local bar_reserve = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0 local bar_reserve = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
local label_h = math.max(Screen:scaleBySize(44), math.floor(self.dimen.h * 0.24)) local label_h = show_label and math.max(Screen:scaleBySize(44), math.floor(self.dimen.h * 0.24)) or 0
local cover_h = math.max(Screen:scaleBySize(60), self.dimen.h - label_h - Size.span.vertical_default - bar_reserve) local label_gap = show_label and Size.span.vertical_default or 0
local cover_w = math.min(self.dimen.w - 2 * Size.padding.default, math.floor(cover_h * 0.68)) local max_cover_w = math.max(1, self.dimen.w - 2 * Size.padding.default)
cover_h = math.min(cover_h, self.dimen.h - label_h - Size.span.vertical_default - bar_reserve) local available_cover_h = math.max(1, self.dimen.h - label_h - label_gap - bar_reserve)
local cover_h = math.min(available_cover_h, math.floor(max_cover_w / 0.68))
cover_h = math.max(math.min(Screen:scaleBySize(60), available_cover_h), cover_h)
local cover_w = math.min(max_cover_w, math.floor(cover_h * 0.68))
local path = self.menu:cachedThumbnailPath(book) local path = self.menu:cachedThumbnailPath(book)
local state = self.menu:thumbnailState(book) local state = self.menu:thumbnailState(book)
local downloaded = self.menu:isOnDevice(book)
local selected = self.menu.bulkIsBookSelected and self.menu:bulkIsBookSelected(book)
local content = VerticalGroup:new{ align = "center" } local content = VerticalGroup:new{ align = "center" }
table.insert(content, CatalogWidgets.buildCoverWidget(book, cover_w, cover_h, path, state)) table.insert(
content,
CatalogWidgets.buildCoverWithStateBadges(book, cover_w, cover_h, path, state, downloaded, selected))
local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, cover_w) local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, cover_w)
if bar then if bar then
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default }) table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(content, bar) table.insert(content, bar)
end end
table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default }) if show_label then
table.insert(content, TextBoxWidget:new{ local label_text = shortText(book and book.title or _("Untitled"), 30)
text = self.menu:cellLabel(book), local label_w = math.max(1, self.dimen.w - 2 * Size.padding.tiny)
width = self.dimen.w - 2 * Size.padding.tiny, table.insert(content, VerticalSpan:new{ width = Size.span.vertical_default })
height = label_h, table.insert(content, TextBoxWidget:new{
alignment = "center", text = label_text,
face = Font:getFace("x_smallinfofont"), width = label_w,
height_overflow_show_ellipsis = true, height = label_h,
}) alignment = "center",
fgcolor = selectedTextColor(selected),
bgcolor = selectedTextBgColor(selected),
face = Font:getFace("cfont", mosaicLabelFontSize(label_text, label_w, label_h)),
height_overflow_show_ellipsis = true,
})
end
self[1] = CenterContainer:new{ local body = CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h }, dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
content, content,
} }
self[1] = FrameContainer:new{
width = self.dimen.w,
height = self.dimen.h,
margin = 0,
padding = 0,
bordersize = 0,
background = selectedBackground(selected),
body,
}
end end
function MosaicItem:onTapSelect() function MosaicItem:onTapSelect()
@@ -235,7 +328,11 @@ function MosaicItem:onTapSelect()
end end
function MosaicItem:onHoldSelect() function MosaicItem:onHoldSelect()
self.menu:onMenuSelect(self.entry) if self.menu.onMenuHoldSelect then
self.menu:onMenuHoldSelect(self.entry)
else
self.menu:onMenuSelect(self.entry)
end
return true return true
end end
@@ -286,6 +383,8 @@ function ListItem:init()
local path = self.menu:cachedThumbnailPath(book) local path = self.menu:cachedThumbnailPath(book)
local state = self.menu:thumbnailState(book) local state = self.menu:thumbnailState(book)
local downloaded = self.menu:isOnDevice(book)
local selected = self.menu.bulkIsBookSelected and self.menu:bulkIsBookSelected(book)
local text_col = VerticalGroup:new{ align = "left" } local text_col = VerticalGroup:new{ align = "left" }
table.insert(text_col, TextBoxWidget:new{ table.insert(text_col, TextBoxWidget:new{
@@ -295,6 +394,8 @@ function ListItem:init()
height_adjust = true, height_adjust = true,
alignment = "left", alignment = "left",
bold = true, bold = true,
fgcolor = selectedTextColor(selected),
bgcolor = selectedTextBgColor(selected),
face = Font:getFace("cfont", title_font), face = Font:getFace("cfont", title_font),
height_overflow_show_ellipsis = true, height_overflow_show_ellipsis = true,
}) })
@@ -306,6 +407,8 @@ function ListItem:init()
height = subtitle_h, height = subtitle_h,
height_adjust = true, height_adjust = true,
alignment = "left", alignment = "left",
fgcolor = selectedTextColor(selected),
bgcolor = selectedTextBgColor(selected),
face = Font:getFace("cfont", subtitle_font), face = Font:getFace("cfont", subtitle_font),
height_overflow_show_ellipsis = true, height_overflow_show_ellipsis = true,
}) })
@@ -326,7 +429,7 @@ function ListItem:init()
dimen = row_dimen:copy(), dimen = row_dimen:copy(),
CenterContainer:new{ CenterContainer:new{
dimen = Geom:new{ w = left_w, h = inner_h }, dimen = Geom:new{ w = left_w, h = inner_h },
CatalogWidgets.buildCoverWidget(book, cover_w, cover_h, path, state), CatalogWidgets.buildCoverWithStateBadges(book, cover_w, cover_h, path, state, downloaded, selected),
}, },
}, },
LeftContainer:new{ LeftContainer:new{
@@ -347,6 +450,8 @@ function ListItem:init()
height = text_h, height = text_h,
height_adjust = true, height_adjust = true,
alignment = "right", alignment = "right",
fgcolor = selectedTextColor(selected),
bgcolor = selectedTextBgColor(selected),
face = Font:getFace("cfont", side_font), face = Font:getFace("cfont", side_font),
height_overflow_show_ellipsis = true, height_overflow_show_ellipsis = true,
}, },
@@ -354,7 +459,6 @@ function ListItem:init()
}, },
}) })
end end
local content = VerticalGroup:new{ align = "left" } local content = VerticalGroup:new{ align = "left" }
table.insert(content, row) table.insert(content, row)
table.insert(content, LineWidget:new{ table.insert(content, LineWidget:new{
@@ -368,7 +472,7 @@ function ListItem:init()
margin = 0, margin = 0,
padding = 0, padding = 0,
bordersize = 0, bordersize = 0,
background = Blitbuffer.COLOR_WHITE, background = selectedBackground(selected),
content, content,
} }
end end
@@ -379,7 +483,11 @@ function ListItem:onTapSelect()
end end
function ListItem:onHoldSelect() function ListItem:onHoldSelect()
self.menu:onMenuSelect(self.entry) if self.menu.onMenuHoldSelect then
self.menu:onMenuHoldSelect(self.entry)
else
self.menu:onMenuSelect(self.entry)
end
return true return true
end end
@@ -419,19 +527,26 @@ function CatalogWidgets.coverCardWidth(card_h, with_progress)
return cover_w + 2 * COVER_CARD_PAD + 2 * CARD_BORDER return cover_w + 2 * COVER_CARD_PAD + 2 * CARD_BORDER
end end
function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state) function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state, downloaded)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state) local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state)
local badge = CatalogWidgets.buildReadStatusBadge(book, math.floor(math.min(width, height) * 0.18)) local read_badge = CatalogWidgets.buildReadStatusBadge(book, math.floor(math.min(width, height) * 0.18))
if not badge then return cover end if not downloaded and not read_badge then return cover end
badge.overlap_align = "right"
local dimen = Geom:new{ w = width, h = height } local group = OverlapGroup:new{
return OverlapGroup:new{ dimen = Geom:new{ w = width, h = height },
dimen = dimen:copy(),
allow_mirroring = false, allow_mirroring = false,
cover, cover,
badge,
} }
if downloaded then
local badge = CatalogWidgets.buildDownloadedBadge(math.floor(math.min(width, height) * 0.18))
badge.overlap_align = "left"
table.insert(group, badge)
end
if read_badge then
read_badge.overlap_align = "right"
table.insert(group, read_badge)
end
return group
end end
-- A thin horizontal rule used to separate dashboard sections. -- A thin horizontal rule used to separate dashboard sections.
@@ -493,11 +608,12 @@ function DashboardCoverCard:init()
local path = self.menu:cachedThumbnailPath(book) local path = self.menu:cachedThumbnailPath(book)
local state = self.menu:thumbnailState(book) local state = self.menu:thumbnailState(book)
local downloaded = self.menu:isOnDevice(book)
local col = VerticalGroup:new{ align = "center" } local col = VerticalGroup:new{ align = "center" }
table.insert(col, CenterContainer:new{ table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = cover_h }, dimen = Geom:new{ w = inner_w, h = cover_h },
CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state), CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state, downloaded),
}) })
if bar_h > 0 then if bar_h > 0 then
table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default }) table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default })
@@ -516,7 +632,11 @@ function DashboardCoverCard:onTapSelect()
end end
function DashboardCoverCard:onHoldSelect() function DashboardCoverCard:onHoldSelect()
self.menu:onMenuSelect(self.entry) if self.menu.onMenuHoldSelect then
self.menu:onMenuHoldSelect(self.entry)
else
self.menu:onMenuSelect(self.entry)
end
return true return true
end end
+15 -1
View File
@@ -38,7 +38,7 @@ local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitSweep = require("bookorbit_sweep") local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater") local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.0.1" local PLUGIN_VERSION = "1.1.0"
local SYNC_STRATEGY = { local SYNC_STRATEGY = {
PROMPT = 1, PROMPT = 1,
@@ -76,6 +76,7 @@ BookOrbit.default_settings = {
catalog_sort = "recently_added", catalog_sort = "recently_added",
catalog_grid_cols = 3, catalog_grid_cols = 3,
catalog_grid_rows = 3, catalog_grid_rows = 3,
catalog_mosaic_show_titles = false,
catalog_recent_searches = {}, catalog_recent_searches = {},
catalog_auto_open = "off", catalog_auto_open = "off",
catalog_dashboard_cache = nil, catalog_dashboard_cache = nil,
@@ -594,6 +595,18 @@ function BookOrbit:dashboardMenuItems(catalog)
table.insert(items, item) table.insert(items, item)
end end
end end
if catalog then
table.insert(items, {
text = _("Close BookOrbit"),
separator = true,
callback = function()
local menu_container = self.dashboard_menu_container
self.dashboard_menu_container = nil
if menu_container then UIManager:close(menu_container) end
catalog:onCloseAllMenus()
end,
})
end
return items return items
end end
@@ -637,6 +650,7 @@ function BookOrbit:openCatalogBrowser(prefer_cached_dashboard)
title = _("BookOrbit"), title = _("BookOrbit"),
api = self:apiOpts(), api = self:apiOpts(),
settings = self.settings, settings = self.settings,
path = self.path,
prefer_cached_dashboard = prefer_cached_dashboard, prefer_cached_dashboard = prefer_cached_dashboard,
save_settings = function() save_settings = function()
G_reader_settings:flush() G_reader_settings:flush()
+5 -1
View File
@@ -12,7 +12,7 @@ The server loads environment variables from `.env` in this directory. See `.env.
## API docs ## API docs
Generated OpenAPI docs are disabled by default. Enable them with `SWAGGER_ENABLED=true`: OpenAPI docs are disabled by default. Enable them with `SWAGGER_ENABLED=true`:
```bash ```bash
SWAGGER_ENABLED=true pnpm start:dev SWAGGER_ENABLED=true pnpm start:dev
@@ -23,6 +23,10 @@ Then open:
- Swagger UI: http://localhost:3000/api/docs - Swagger UI: http://localhost:3000/api/docs
- OpenAPI JSON: http://localhost:3000/api/docs-json - OpenAPI JSON: http://localhost:3000/api/docs-json
Dev watch uses a Nest config without the Swagger metadata generator so the
server does not restart immediately after startup. Full build output still uses
the standard Nest config and generates `src/metadata.ts` for enriched docs.
## Module structure ## Module structure
Each feature is a self-contained NestJS module under `src/modules/`: Each feature is a self-contained NestJS module under `src/modules/`:
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true,
"plugins": []
}
}
+2 -2
View File
@@ -23,8 +23,8 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "sh -c 'export $(grep -v \"^#\" .env | xargs) 2>/dev/null; exec nest start --watch'", "start:dev": "sh -c 'export $(grep -v \"^#\" .env | xargs) 2>/dev/null; exec nest start --watch --config nest-cli.dev.json'",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch --config nest-cli.dev.json",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"db:generate": "drizzle-kit generate --name", "db:generate": "drizzle-kit generate --name",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
+42
View File
@@ -0,0 +1,42 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const serverRoot = join(process.cwd());
async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(join(serverRoot, path), 'utf8')) as T;
}
type NestCliConfig = {
compilerOptions?: {
builder?: string;
typeCheck?: boolean;
plugins?: Array<{ name?: string }>;
};
};
type PackageJson = {
scripts?: Record<string, string>;
};
describe('Nest CLI watch configuration', () => {
it('keeps dev watch type-checking without generating Swagger metadata', async () => {
const devConfig = await readJson<NestCliConfig>('nest-cli.dev.json');
const packageJson = await readJson<PackageJson>('package.json');
expect(devConfig.compilerOptions?.builder).toBe('swc');
expect(devConfig.compilerOptions?.typeCheck).toBe(true);
expect(devConfig.compilerOptions?.plugins).toEqual([]);
expect(packageJson.scripts?.['start:dev']).toContain('--config nest-cli.dev.json');
expect(packageJson.scripts?.['start:debug']).toContain('--config nest-cli.dev.json');
});
it('keeps the standard build config wired to Swagger metadata generation', async () => {
const buildConfig = await readJson<NestCliConfig>('nest-cli.json');
expect(buildConfig.compilerOptions?.builder).toBe('swc');
expect(buildConfig.compilerOptions?.typeCheck).toBe(true);
expect(buildConfig.compilerOptions?.plugins).toContainEqual(expect.objectContaining({ name: '@nestjs/swagger' }));
});
});
@@ -66,20 +66,23 @@ describe('KOReader plugin update source wiring', () => {
expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")'); expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")');
}); });
it('keeps the primary detail download as a default download and puts customization in options', async () => { it('keeps book action downloads compact without duplicating download options', async () => {
const catalog = await readPluginFile('bookorbit_catalog.lua'); const catalog = await readPluginFile('bookorbit_catalog.lua');
const download = await readPluginFile('bookorbit_catalog_download.lua'); const download = await readPluginFile('bookorbit_catalog_download.lua');
const detailActionsBlock = catalog.slice( const detailActionsBlock = catalog.slice(
catalog.indexOf('function BookOrbitCatalog:showBookActionSheet(detail, opts)'),
catalog.indexOf('function BookOrbitCatalog:showDetailActions()'), catalog.indexOf('function BookOrbitCatalog:showDetailActions()'),
catalog.indexOf('function BookOrbitCatalog:showSetStatusDialog'),
); );
const detailHeaderBlock = catalog.slice( const detailHeaderBlock = catalog.slice(
catalog.indexOf('function BookOrbitCatalog:buildDetailHeader'), catalog.indexOf('function BookOrbitCatalog:buildDetailHeader'),
catalog.indexOf('function BookOrbitCatalog:updateDetailItems'), catalog.indexOf('function BookOrbitCatalog:updateDetailItems'),
); );
expect(detailActionsBlock).toContain('text = _("Download options")'); expect(detailActionsBlock).toContain('text = _("Download")');
expect(detailActionsBlock).toContain('self:showDownloadOptions(detail)'); expect(detailActionsBlock).toContain('self:downloadDefaultFile(detail, supported_files[1])');
expect(detailActionsBlock).toContain('self:showFileChoices(detail)');
expect(detailActionsBlock).not.toContain('text = _("Download options")');
expect(detailActionsBlock).not.toContain('self:showDownloadOptions(detail)');
expect(detailHeaderBlock).toContain('if #supported_files == 1 then'); expect(detailHeaderBlock).toContain('if #supported_files == 1 then');
expect(detailHeaderBlock).toContain('self:downloadDefaultFile(detail, supported_files[1])'); expect(detailHeaderBlock).toContain('self:downloadDefaultFile(detail, supported_files[1])');
expect(detailHeaderBlock).toContain('self:showFileChoices(detail)'); expect(detailHeaderBlock).toContain('self:showFileChoices(detail)');
@@ -105,4 +108,100 @@ describe('KOReader plugin update source wiring', () => {
expect(sweep).toContain('on_finish = opts.on_finish'); expect(sweep).toContain('on_finish = opts.on_finish');
expect(sweep).toContain('pcall(ctx.on_finish, err)'); expect(sweep).toContain('pcall(ctx.on_finish, err)');
}); });
it('wires bulk catalog downloads through a dedicated mixin and selection UI hooks', async () => {
const catalog = await readPluginFile('bookorbit_catalog.lua');
const bulk = await readPluginFile('bookorbit_catalog_bulk_download.lua');
const download = await readPluginFile('bookorbit_catalog_download.lua');
const main = await readPluginFile('main.lua');
const widgets = await readPluginFile('bookorbit_catalog_widgets.lua');
const downloadIcon = await readPluginFile('assets/bookorbit.download.svg');
const refreshTapBlock = catalog.slice(
catalog.indexOf('function BookOrbitCatalog:onRefreshButtonTap()'),
catalog.indexOf('function BookOrbitCatalog:loadBookDetail'),
);
expect(catalog).toContain('local CatalogBulkDownload = require("bookorbit_catalog_bulk_download")');
expect(catalog).toContain('CatalogBulkDownload.install(BookOrbitCatalog)');
expect(catalog).toContain('self:initBulkDownloadState()');
expect(catalog).toContain('text = _("Select books")');
expect(catalog).toContain('text = _("Download...")');
expect(catalog).toContain('function BookOrbitCatalog:showDownloadActions()');
expect(catalog).toContain('text = _("Download selected")');
expect(catalog).toContain('text = _("Download this page")');
expect(catalog).toContain('text = _("Download all in this list")');
expect(catalog).toContain('text = _("Settings")');
expect(catalog).toContain('catalog_mosaic_show_titles');
expect(catalog).toContain('function BookOrbitCatalog:setMosaicShowTitles(show_titles)');
expect(catalog).toContain('text = titles_label');
expect(catalog).toContain('self:showBulkSelectionActions()');
expect(catalog).toContain('function BookOrbitCatalog:onMenuHoldSelect(item)');
expect(catalog).toContain('function BookOrbitCatalog:showBookActionSheet(detail, opts)');
expect(catalog).toContain('function BookOrbitCatalog:showBookActionSheetForEntry(item)');
expect(catalog).toContain('function BookOrbitCatalog:isBulkSelectionActive()');
expect(catalog).toContain('self:bulkHandleContextChange(self.current_context)');
expect(catalog).toContain('function BookOrbitCatalog:titleBarSearchIcon()');
expect(catalog).toContain('local DOWNLOAD_ICON = "appbar.filebrowser"');
expect(catalog).toContain('local DOWNLOAD_ICON_FILE = "bookorbit.download.svg"');
expect(catalog).toContain('local BookOrbitIconButton = IconButton:extend{ file = nil }');
expect(catalog).toContain('file = self.search_icon_file');
expect(catalog).toContain('function BookOrbitCatalog:titleBarSearchIconFile()');
expect(catalog).toContain('search_icon_file = self:titleBarSearchIconFile()');
expect(catalog).toContain('if self:detailMode() then return nil end');
expect(catalog).toContain('return self:isBulkSelectionActive() and DOWNLOAD_ICON or "appbar.search"');
expect(catalog).toContain('function BookOrbitCatalog:titleBarRefreshIcon()');
expect(catalog).not.toContain('"move.down"');
expect(catalog).toContain('search_icon_enabled = self:titleBarSearchEnabled()');
expect(catalog).toContain('self:confirmBulkBooks(self:bulkSelectedBooks(), _("Selected books")');
expect(refreshTapBlock).toContain('self:bulkExitSelectionMode()');
expect(refreshTapBlock).not.toContain('self:bulkClearSelectedBooks(true)');
expect(catalog).toContain('self:showBookActionSheetForEntry(item)');
expect(catalog).toContain('allow_select = item.kind == "book" and self:bookMode()');
expect(catalog).toContain('text = _("Close BookOrbit")');
expect(catalog).not.toContain('"appbar.download"');
expect(catalog).not.toContain('close_callback = function() self:onClose() end');
expect(bulk).toContain('function Catalog:confirmBulkAllMatching()');
expect(bulk).toContain('function Catalog:bulkClearSelectedBooks(redraw)');
expect(bulk).toContain('function Catalog:bulkQueueStep(ctx)');
expect(bulk).toContain('text = _("Cancel after current file")');
expect(bulk).toContain('text = _("Retry failed")');
expect(bulk).toContain('label = _("EPUB first")');
expect(bulk).toContain('label = _("PDF first")');
expect(bulk).toContain('label = _("Comics first")');
expect(bulk).toContain('label = _("Skip existing")');
expect(bulk).toContain('text = _("Close BookOrbit")');
expect(bulk).not.toContain('function Catalog:showDashboardActions()');
expect(bulk).not.toContain('Download Continue reading');
expect(bulk).not.toContain('Download Discover');
expect(bulk).not.toContain('Download dashboard books');
expect(download).toContain('local on_catalog_page = (self.bookMode and self:bookMode())');
expect(download).toContain('elseif self.updateItems and on_catalog_page then');
expect(main).toContain('text = _("Close BookOrbit")');
expect(main).toContain('catalog:onCloseAllMenus()');
expect(main).toContain('path = self.path');
expect(main).toContain('catalog_mosaic_show_titles = false');
expect(widgets).toContain('function CatalogWidgets.buildSelectionBadge(max_width)');
expect(widgets).toContain('function CatalogWidgets.buildDownloadedBadge(max_width)');
expect(widgets).toContain('icon = "appbar.filebrowser"');
expect(widgets).toContain('function CatalogWidgets.buildCoverWithStateBadges');
expect(widgets).toContain('local show_label = self.menu.mosaic_show_titles == true');
expect(widgets).toContain('local label_text = shortText(book and book.title or _("Untitled"), 30)');
expect(widgets).not.toContain('local label_text = self.menu:cellLabel(book)');
expect(widgets).toContain('local function mosaicLabelFontSize(text, width, height)');
expect(widgets).toContain('face = Font:getFace("cfont", mosaicLabelFontSize(label_text, label_w, label_h))');
expect(widgets).toContain('local SELECTED_BACKGROUND = Blitbuffer.COLOR_LIGHT_GRAY');
expect(widgets).toContain('fgcolor = selectedTextColor(selected)');
expect(widgets).toContain('bgcolor = selectedTextBgColor(selected)');
expect(widgets).toContain('background = selectedBackground(selected)');
expect(widgets).toContain('function DashboardCoverCard:onHoldSelect()');
expect(widgets).toContain('self.menu:onMenuHoldSelect(self.entry)');
expect(widgets).toContain('self.menu:bulkIsBookSelected(book)');
expect(widgets).toContain('self.menu:isOnDevice(book)');
expect(downloadIcon).toContain('<svg');
expect(downloadIcon).toContain('M24 7v24');
});
}); });
@@ -160,8 +160,8 @@ describe('OpdsBookService', () => {
await expect(service.getRecentBooksPage(5, 2, 15)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 }); await expect(service.getRecentBooksPage(5, 2, 15)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 });
}); });
it('handles getRandomBooks guard branches and wrapped id selection', async () => { it('handles getRandomBooks guard branches and randomized id selection', async () => {
const { service } = makeService([[{ minId: null, maxId: null }], [{ minId: 10, maxId: 12 }], [{ id: 11 }], [{ id: 10 }]]); const { service, db } = makeService([[], [{ id: 11 }, { id: 10 }]]);
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
const fetchSpy = vi.spyOn(testable(service), 'fetchBookEntries'); const fetchSpy = vi.spyOn(testable(service), 'fetchBookEntries');
@@ -175,9 +175,13 @@ describe('OpdsBookService', () => {
accessSpy.mockResolvedValueOnce([1]); accessSpy.mockResolvedValueOnce([1]);
fetchSpy.mockResolvedValueOnce([{ id: 11 }, { id: 10 }]); fetchSpy.mockResolvedValueOnce([{ id: 11 }, { id: 10 }]);
vi.spyOn(Math, 'random').mockReturnValue(0.5);
await expect(service.getRandomBooks(7, 2)).resolves.toEqual([{ id: 11 }, { id: 10 }]); await expect(service.getRandomBooks(7, 2)).resolves.toEqual([{ id: 11 }, { id: 10 }]);
expect(fetchSpy).toHaveBeenCalledWith([11, 10]); expect(fetchSpy).toHaveBeenCalledWith([11, 10]);
const chains = (db.select as ReturnType<typeof vi.fn>).mock.results.map((r) => r.value as Record<string, unknown>);
const orderBy = chains.at(-1)!['orderBy'] as ReturnType<typeof vi.fn>;
const values = orderBy.mock.calls.flat().flatMap((arg: unknown) => collectValues(arg));
expect(values).toContain('random()');
}); });
it('returns distinct authors and membership-backed series with and without access', async () => { it('returns distinct authors and membership-backed series with and without access', async () => {
+5 -29
View File
@@ -1,5 +1,5 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { SQL, and, count, eq, gte, ilike, inArray, lt, or, sql } from 'drizzle-orm'; import { SQL, and, count, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db'; import { DB } from '../../db';
@@ -294,38 +294,14 @@ export class OpdsBookService {
baseClauses.push(...buildContentFilterClauses(contentFilters, this.db)); baseClauses.push(...buildContentFilterClauses(contentFilters, this.db));
} }
const baseFilter = and(...baseClauses)!; const baseFilter = and(...baseClauses)!;
const [bounds] = await this.db const idRows = await this.db
.select({
minId: sql<number | null>`min(${books.id})`,
maxId: sql<number | null>`max(${books.id})`,
})
.from(books)
.where(baseFilter);
if (bounds?.minId == null || bounds.maxId == null || bounds.minId > bounds.maxId) return [];
const range = bounds.maxId - bounds.minId + 1;
const anchorId = bounds.minId + Math.floor(Math.random() * range);
const firstPass = await this.db
.select({ id: books.id }) .select({ id: books.id })
.from(books) .from(books)
.where(and(baseFilter, gte(books.id, anchorId))) .where(baseFilter)
.orderBy(books.id) .orderBy(sql`random()`)
.limit(count); .limit(count);
const remaining = count - firstPass.length; const ids = idRows.map((row) => row.id);
const secondPass =
remaining > 0
? await this.db
.select({ id: books.id })
.from(books)
.where(and(baseFilter, lt(books.id, anchorId)))
.orderBy(books.id)
.limit(remaining)
: [];
const ids = [...firstPass, ...secondPass].map((row) => row.id);
if (ids.length === 0) return []; if (ids.length === 0) return [];
return this.fetchBookEntries(ids); return this.fetchBookEntries(ids);
} }