mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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/`:
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user