feat(koreader): enhance settings and plugin behavior (#669)

* feat(koreader): add device file naming and improve plugin downloads (BO-658)

* fix(koreader): secure download paths and test collisions

* fix(koreader): preserve bulk collision identities

* fix(koreader): improve device file naming validation and path generation

* fix(koreader): remove duplicate settings lookup and audit file naming changes

* fix(koreader): resolve remaining CodeRabbit review issues

* fix(koreader): validate book detail query and finalize file naming release

validate book detail deviceId via DTO and ValidationPipe

localize new KOReader file naming settings (de, nl, sl)

bump KOReader plugin version to 1.3.0

* chore: rerun CI

* chore: normalized Closes #658 to not have embedded URL to issue. This is failing PR Conventions test

* test(koreader): align source and device removal assertions

* test(koreader): cover file naming settings flows

* test(koreader): cover file naming sync requests

* test(koreader): close remaining patch coverage gaps

* test(koreader): cover file naming server paths

* docs: add fixed2 modifier to file naming reference

* feat(koreader): support custom file naming patterns for KOReader sync

Add support for configurable KOReader file naming patterns, allowing users to define dynamic path templates for sync targets. Include UI draft settings, preview updates, and server path resolution.

Closes #658

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
Co-authored-by: jadehawk <jadehawk@hotmail.com>
This commit is contained in:
Jadehawk
2026-07-14 13:26:27 -04:00
committed by GitHub
parent 72a56575c7
commit ec84703cef
60 changed files with 19424 additions and 831 deletions
@@ -336,7 +336,7 @@ function BookOrbitApi:catalogBooks(params)
end
function BookOrbitApi:catalogBook(book_id)
return self:request("GET", "/koreader/plugin/catalog/books/" .. tostring(book_id))
return self:request("GET", self:query("/koreader/plugin/catalog/books/" .. tostring(book_id), { deviceId = self.device_id }))
end
function BookOrbitApi:downloadCatalogFile(file_id, local_path, progress_cb)
@@ -24,8 +24,40 @@ local safeFilenameBase = CatalogUtil.safeFilenameBase
local shortText = CatalogUtil.shortText
local STEP_DELAY = 0.15
local NEXT_ITEM_DELAY = 0.02
local PROGRESS_THROTTLE = 1
local FAILED_TITLES_LIMIT = 8
local TITLE_LINE_LENGTH = 38
local function fixedTwoLineTitle(book)
local title = shortText(book and book.title or _("Untitled"), TITLE_LINE_LENGTH * 2)
if #title <= TITLE_LINE_LENGTH then
return title .. "\n "
end
local split_at = TITLE_LINE_LENGTH
for index = TITLE_LINE_LENGTH, 1, -1 do
if title:sub(index, index) == " " then
split_at = index - 1
break
end
end
local first = title:sub(1, split_at):gsub("%s+$", "")
local second = title:sub(split_at + 1):gsub("^%s+", "")
return first .. "\n" .. shortText(second, TITLE_LINE_LENGTH)
end
local function pathKey(path)
return tostring(path or ""):gsub("\\", "/"):lower()
end
local function appendPathIdentity(path, identity)
local stem, extension = path:match("^(.*)(%.[^./]+)$")
if not stem then
stem, extension = path, ""
end
return stem .. " [" .. tostring(identity) .. "]" .. extension
end
local FORMAT_PRESETS = {
{
@@ -521,10 +553,14 @@ function CatalogBulkDownload.install(Catalog)
skipped_on_device = 0,
skipped_unsupported = 0,
skipped_existing = 0,
path_conflicts = 0,
failed = 0,
},
failed_books = {},
failed_titles = {},
existing_files = {},
path_conflicts = {},
destination_paths = {},
cancel_requested = false,
cancelled = false,
progress_dialog = nil,
@@ -533,6 +569,8 @@ function CatalogBulkDownload.install(Catalog)
}
self.bulk_running = true
self.bulk_ctx = ctx
UIManager:preventStandby()
ctx.standby_prevented = true
if source.clear_selection then
self:bulkClearSelection(true)
end
@@ -561,6 +599,12 @@ function CatalogBulkDownload.install(Catalog)
end)
end
function Catalog:bulkReleaseStandby(ctx)
if not ctx or not ctx.standby_prevented then return end
ctx.standby_prevented = false
UIManager:allowStandby()
end
function Catalog:bulkAbort(ctx, message)
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
@@ -568,6 +612,7 @@ function CatalogBulkDownload.install(Catalog)
end
self.bulk_running = false
self.bulk_ctx = nil
self:bulkReleaseStandby(ctx)
UIManager:show(InfoMessage:new{ text = message, timeout = 4 })
end
@@ -589,9 +634,15 @@ function CatalogBulkDownload.install(Catalog)
buttons = {
{
{
text = _("Cancel after current file"),
text = ctx.cancel_requested and _("Stopping...") or _("Cancel after current file"),
enabled = not ctx.cancel_requested,
callback = function()
if ctx.cancel_requested then return end
ctx.cancel_requested = true
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
ctx.progress_dialog = nil
end
self:bulkSetProgress(ctx, _("Stopping after current file..."), true)
end,
},
@@ -603,6 +654,30 @@ function CatalogBulkDownload.install(Catalog)
UIManager:forceRePaint()
end
function Catalog:bulkProgressText(ctx, status, book)
local counts = ctx.counts
local lines = {
T(_("Processing %1/%2"), ctx.index, ctx.total),
}
table.insert(lines, fixedTwoLineTitle(book))
if status then
table.insert(lines, status)
end
table.insert(lines, T(_("Downloaded: %1\nAlready on device: %2\nExisting file (Not in BookOrbit): %3\nRenamed conflicts: %4\nUnsupported: %5\nFailed: %6"),
counts.downloaded,
counts.skipped_on_device,
counts.skipped_existing,
counts.path_conflicts or 0,
counts.skipped_unsupported,
counts.failed
))
return table.concat(lines, "\n\n")
end
function Catalog:bulkShowStatus(ctx, status, book, force)
self:bulkSetProgress(ctx, self:bulkProgressText(ctx, status, book), force == true)
end
function Catalog:bulkQueueStep(ctx)
if ctx ~= self.bulk_ctx then return end
if ctx.cancel_requested then
@@ -626,54 +701,96 @@ function CatalogBulkDownload.install(Catalog)
self:bulkRecordFailure(ctx, book)
end
UIManager:scheduleIn(STEP_DELAY, function()
if ctx.cancel_requested then
ctx.cancelled = true
self:bulkFinish(ctx)
return
end
UIManager:scheduleIn(NEXT_ITEM_DELAY, function()
self:bulkQueueStep(ctx)
end)
end
function Catalog:bulkProcessBook(ctx, book)
self:bulkShowStatus(ctx, _("Checking book..."), book, ctx.index % 5 == 0)
if self:isOnDevice(book) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
self:bulkShowStatus(ctx, _("Already on device - skipping"), book, ctx.index % 5 == 0)
return
end
if not self:bulkSupportedFormatHint(book) then
ctx.counts.skipped_unsupported = ctx.counts.skipped_unsupported + 1
self:bulkShowStatus(ctx, _("No supported format - skipping"), book, true)
return
end
self:bulkSetProgress(ctx, T(_("Preparing %1/%2..."), ctx.index, ctx.total), true)
self:bulkShowStatus(ctx, _("Loading book details..."), book, 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)
self:bulkShowStatus(ctx, _("Could not load book details"), book, true)
return
end
if self:isOnDevice(detail) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
self:bulkShowStatus(ctx, _("Already on device - skipping"), detail, true)
return
end
local file = self:bulkChooseFile(detail)
if not file then
ctx.counts.skipped_unsupported = ctx.counts.skipped_unsupported + 1
self:bulkShowStatus(ctx, _("No supported format - skipping"), detail, true)
return
end
local filename = safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
local local_path = self:getLocalDownloadPath(filename, filetype)
local book_id = detail.id or book.id
local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath)
local original_path = local_path
local owner = ctx.destination_paths[pathKey(local_path)]
if owner and owner.book_id ~= book_id then
local identity = book_id or file.id
local candidate = appendPathIdentity(local_path, identity)
local suffix = 2
while ctx.destination_paths[pathKey(candidate)] or lfs.attributes(candidate) do
candidate = appendPathIdentity(local_path, tostring(identity) .. "-" .. tostring(suffix))
suffix = suffix + 1
end
local_path = candidate
ctx.counts.path_conflicts = ctx.counts.path_conflicts + 1
table.insert(ctx.path_conflicts, {
book_id = book_id,
title = bookTitle(detail),
original_path = original_path,
resolved_path = local_path,
conflicting_book_id = owner.book_id,
})
logger.warn("BookOrbit: bulk destination path conflict renamed", book_id, owner.book_id, original_path, local_path)
self:bulkShowStatus(ctx, _("Path conflict - using unique filename"), detail, true)
end
if lfs.attributes(local_path) and self:bulkExistingPolicy().id == "skip" then
ctx.counts.skipped_existing = ctx.counts.skipped_existing + 1
table.insert(ctx.existing_files, { book_id = book_id, title = bookTitle(detail), path = local_path })
logger.warn("BookOrbit: existing destination skipped", detail.id, local_path)
self:bulkShowStatus(ctx, _("File already exists - skipping"), detail, true)
return
end
local ok, linked = self:bulkDownloadFile(ctx, detail, file, local_path)
if not ok then
self:bulkRecordFailure(ctx, book)
self:bulkShowStatus(ctx, _("Download failed"), detail, true)
return
end
ctx.counts.downloaded = ctx.counts.downloaded + 1
ctx.destination_paths[pathKey(local_path)] = { book_id = book_id, file_id = file.id }
if linked then ctx.counts.linked = ctx.counts.linked + 1 end
self:bulkShowStatus(ctx, _("Download complete"), detail, true)
end
function Catalog:bulkChooseFile(detail)
@@ -702,22 +819,22 @@ function CatalogBulkDownload.install(Catalog)
local total = file.sizeBytes
local last_bucket = -1
local function on_progress(received)
local text, bucket
local status, 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:\n%3\n\n%4"), ctx.index, ctx.total, filename, pct .. "%")
status = T(_("Downloading - %1"), pct .. "%")
else
bucket = math.floor(received / (256 * 1024))
if bucket == last_bucket then return end
text = T(_("Downloading %1/%2:\n%3\n\n%4"), ctx.index, ctx.total, filename, formatBytes(received))
status = T(_("Downloading - %1"), formatBytes(received))
end
last_bucket = bucket
self:bulkSetProgress(ctx, text, true)
self:bulkShowStatus(ctx, status, detail, true)
end
self:bulkSetProgress(ctx, T(_("Downloading %1/%2:\n%3"), ctx.index, ctx.total, filename), true)
self:bulkShowStatus(ctx, _("Downloading..."), detail, 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)
@@ -741,6 +858,7 @@ function CatalogBulkDownload.install(Catalog)
end
function Catalog:bulkFinish(ctx)
self:bulkReleaseStandby(ctx)
if ctx.progress_dialog then
UIManager:close(ctx.progress_dialog)
ctx.progress_dialog = nil
@@ -759,10 +877,26 @@ function CatalogBulkDownload.install(Catalog)
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(_("Existing file (Not in BookOrbit): %1"), counts.skipped_existing),
T(_("Renamed path conflicts: %1"), counts.path_conflicts or 0),
T(_("No supported format: %1"), counts.skipped_unsupported),
T(_("Failed: %1"), counts.failed),
}
local existing_files = ctx.existing_files or {}
local path_conflicts = ctx.path_conflicts or {}
if #existing_files > 0 then
table.insert(lines, "")
table.insert(lines, _("Existing files (Not in BookOrbit):"))
for _, entry in ipairs(existing_files) do
table.insert(lines, shortText(entry.title, 52))
table.insert(lines, shortText(entry.path, 72))
end
end
if #path_conflicts > 0 then
table.insert(lines, "")
table.insert(lines, _("Conflicting destinations were renamed with BookOrbit IDs."))
end
if #ctx.failed_titles > 0 then
table.insert(lines, "")
table.insert(lines, _("Failed books:"))
@@ -27,6 +27,17 @@ local safeFilenameBase = CatalogUtil.safeFilenameBase
local CatalogDownload = {}
local function sanitizeDevicePath(device_path)
local normalized = tostring(device_path or ""):gsub("\\", "/"):gsub("^/+", "")
local segments = {}
for segment in normalized:gmatch("[^/]+") do
if segment == ".." then return nil end
if segment ~= "." then table.insert(segments, segment) end
end
if #segments == 0 then return nil end
return table.concat(segments, "/")
end
function CatalogDownload.install(Catalog)
function Catalog:showFileChoices(detail)
local files = self:supportedFiles(detail)
@@ -77,8 +88,14 @@ function CatalogDownload.install(Catalog)
return G_reader_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir")
end
function Catalog:getLocalDownloadPath(filename, filetype)
function Catalog:getLocalDownloadPath(filename, filetype, device_path)
local download_dir = self:getCurrentDownloadDir()
local relative = sanitizeDevicePath(device_path)
if relative then
local parent = relative:match("^(.*)/[^/]+$")
if parent and parent ~= "" then util.makePath(download_dir .. "/" .. parent) end
return (download_dir ~= "/" and download_dir or "") .. "/" .. relative
end
filename = filename .. "." .. string.lower(filetype or "bin")
filename = util.getSafeFilename(filename, download_dir)
return (download_dir ~= "/" and download_dir or "") .. "/" .. filename
@@ -87,7 +104,7 @@ function CatalogDownload.install(Catalog)
function Catalog:downloadDefaultFile(detail, file)
local filename = safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
local local_path = self:getLocalDownloadPath(filename, filetype)
local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath)
self:checkDownloadFile(local_path, detail, file)
end
@@ -142,7 +159,7 @@ function CatalogDownload.install(Catalog)
text = _("Download"),
callback = function()
UIManager:close(dialog)
local local_path = self:getLocalDownloadPath(filename, filetype)
local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath)
self:checkDownloadFile(local_path, detail, file)
end,
},
@@ -291,7 +308,7 @@ function CatalogDownload.install(Catalog)
local state = BookOrbitState.open()
state:rememberFile(local_path, digest)
for index, match in ipairs(body.matches or {}) do
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(digest, match.bookFileId, match.bookId, local_path)
state:flush()
+1 -1
View File
@@ -43,7 +43,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.2.1"
local PLUGIN_VERSION = "1.3.0"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -0,0 +1,268 @@
local scheduled = {}
local prevent_calls = 0
local allow_calls = 0
local shown_widget
package.loaded["ui/bidi"] = {}
package.loaded["datastorage"] = {
getDataDir = function()
return "/tmp"
end,
}
package.loaded["ui/widget/buttondialog"] = {
new = function(_, opts)
opts = opts or {}
function opts:setTitle(title)
self.title = title
end
return opts
end,
}
package.loaded["ui/widget/infomessage"] = {
new = function(_, opts)
return opts or {}
end,
}
package.loaded["ui/widget/confirmbox"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/widget/inputdialog"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/widget/notification"] = package.loaded["ui/widget/infomessage"]
package.loaded["ui/uimanager"] = {
preventStandby = function()
prevent_calls = prevent_calls + 1
end,
allowStandby = function()
allow_calls = allow_calls + 1
end,
scheduleIn = function(_, _, callback)
table.insert(scheduled, callback)
end,
nextTick = function(_, callback)
table.insert(scheduled, callback)
end,
show = function(_, widget)
shown_widget = widget
end,
close = function() end,
forceRePaint = function() end,
}
package.loaded["libs/libkoreader-lfs"] = {
attributes = function()
return nil
end,
}
package.loaded["logger"] = {
dbg = function() end,
warn = function() end,
}
package.loaded["ffi/util"] = {
template = function(text)
return text
end,
}
local made_paths = {}
package.loaded["util"] = {
makePath = function(path)
table.insert(made_paths, path)
end,
getSafeFilename = function(filename)
return filename
end,
}
package.loaded["bookorbit_state"] = {}
package.loaded["gettext"] = function(text)
return text
end
package.loaded["bookorbit_catalog_util"] = {
cloneParams = function(value)
return value or {}
end,
formatBytes = function(value)
return tostring(value)
end,
isSupportedFormat = function()
return true
end,
safeFilenameBase = function()
return "book"
end,
shortText = function(value)
return value
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BulkDownload = require("bookorbit_catalog_bulk_download")
local CatalogDownload = require("bookorbit_catalog_download")
local Catalog = {}
BulkDownload.install(Catalog)
CatalogDownload.install(Catalog)
local realBulkProcessBook = Catalog.bulkProcessBook
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
G_reader_settings = {
readSetting = function(_, key)
if key == "download_dir" then return "/downloads" end
return nil
end,
}
assertEqual(Catalog:getLocalDownloadPath("fallback", "epub", "Series/Book.epub"), "/downloads/Series/Book.epub", "valid device path")
assertEqual(made_paths[1], "/downloads/Series", "valid device parent created")
assertEqual(Catalog:getLocalDownloadPath("fallback", "epub", "./Series//Book.epub"), "/downloads/Series/Book.epub", "dot segments normalized")
assertEqual(Catalog:getLocalDownloadPath("fallback", "epub", "../outside.epub"), "/downloads/fallback.epub", "parent traversal falls back")
assertEqual(Catalog:getLocalDownloadPath("fallback", "epub", "Series\\..\\..\\outside.epub"), "/downloads/fallback.epub", "mixed separator traversal falls back")
assertEqual(#made_paths, 2, "traversal paths do not create directories")
Catalog.settings = {}
Catalog:initBulkDownloadState()
Catalog:startBulkSource{
label = "Empty",
resolve = function()
return {}
end,
}
assertEqual(prevent_calls, 1, "starting a bulk download prevents standby")
assertEqual(allow_calls, 0, "standby remains prevented while preparation is queued")
assertEqual(#scheduled, 1, "preparation is scheduled")
table.remove(scheduled, 1)()
assertEqual(allow_calls, 1, "empty-source abort releases standby")
assertEqual(Catalog.bulk_running, false, "empty-source abort clears running state")
local finish_ctx = {
standby_prevented = true,
counts = {
downloaded = 0,
linked = 0,
skipped_on_device = 0,
skipped_unsupported = 0,
skipped_existing = 0,
failed = 0,
},
failed_books = {},
failed_titles = {},
cancelled = false,
}
prevent_calls = prevent_calls + 1
Catalog.bulk_running = true
Catalog.bulk_ctx = finish_ctx
Catalog.refreshOnDevice = function() end
Catalog.bookMode = function() return false end
Catalog.dashboardMode = function() return false end
Catalog:bulkFinish(finish_ctx)
assertEqual(allow_calls, 2, "normal completion releases standby")
assertEqual(finish_ctx.standby_prevented, false, "completion marks standby lock released")
Catalog:bulkReleaseStandby(finish_ctx)
assertEqual(allow_calls, 2, "standby release is idempotent")
local processed = {}
local cancel_ctx = {
books = { { id = 1 }, { id = 2 } },
index = 0,
total = 2,
cancel_requested = false,
cancelled = false,
counts = {
downloaded = 0,
linked = 0,
skipped_on_device = 0,
skipped_unsupported = 0,
skipped_existing = 0,
path_conflicts = 0,
failed = 0,
},
failed_books = {},
failed_titles = {},
}
Catalog.bulk_running = true
Catalog.bulk_ctx = cancel_ctx
Catalog.bulkProcessBook = function(_, _, book)
table.insert(processed, book.id)
end
Catalog:bulkQueueStep(cancel_ctx)
assertEqual(#processed, 1, "first queue step processes one book")
assertEqual(#scheduled, 1, "next item waits for a UI event window")
cancel_ctx.cancel_requested = true
table.remove(scheduled, 1)()
assertEqual(#processed, 1, "cancel prevents the second book from starting")
assertEqual(cancel_ctx.cancelled, true, "cancel marks the run as stopped")
assertEqual(Catalog.bulk_running, false, "cancel finishes the bulk run")
Catalog.bulkProcessBook = realBulkProcessBook
local collision_ctx = {
index = 1,
counts = {
downloaded = 0,
linked = 0,
skipped_on_device = 0,
skipped_unsupported = 0,
skipped_existing = 0,
path_conflicts = 0,
failed = 0,
},
destination_paths = {},
path_conflicts = {},
existing_files = {},
failed_books = {},
failed_titles = {},
}
local details = {
[1] = { title = "First", files = { { id = 101, format = "epub", devicePath = "Shared/Book.epub" } } },
[2] = { title = "Second", files = { { id = 102, format = "epub", devicePath = "Shared/Book.epub" } } },
[3] = { title = "Third", files = { { id = 103, format = "epub", devicePath = "Shared/Book.epub" } } },
}
local downloaded_paths = {}
Catalog.isOnDevice = function() return false end
Catalog.bulkSupportedFormatHint = function() return true end
Catalog.client = {
catalogBook = function(_, id)
return details[id]
end,
}
Catalog.bulkChooseFile = function(_, detail)
return detail.files[1]
end
Catalog.bulkExistingPolicy = function()
return { id = "skip" }
end
Catalog.bulkDownloadFile = function(_, _, _, _, local_path)
table.insert(downloaded_paths, local_path)
return true, false
end
Catalog.bulkShowStatus = function() end
Catalog:bulkProcessBook(collision_ctx, { id = 1, title = "First" })
collision_ctx.index = 2
Catalog:bulkProcessBook(collision_ctx, { id = 2, title = "Second" })
collision_ctx.index = 3
Catalog:bulkProcessBook(collision_ctx, { id = 3, title = "Third" })
assertEqual(downloaded_paths[1], "/downloads/Shared/Book.epub", "first collision path remains unchanged")
assertEqual(downloaded_paths[2], "/downloads/Shared/Book [2].epub", "second collision path gains book identity")
assertEqual(downloaded_paths[3], "/downloads/Shared/Book [3].epub", "third collision path gains book identity")
assertEqual(collision_ctx.counts.downloaded, 3, "all colliding books download")
assertEqual(collision_ctx.counts.path_conflicts, 2, "collision count recorded")
assertEqual(#collision_ctx.path_conflicts, 2, "collision details recorded")
assertEqual(collision_ctx.path_conflicts[1].conflicting_book_id, 1, "collision owner recorded")
assertEqual(collision_ctx.path_conflicts[1].resolved_path, "/downloads/Shared/Book [2].epub", "resolved collision path recorded")
assertEqual(collision_ctx.destination_paths["/downloads/shared/book.epub"].book_id, 1, "original path ownership retained")
assertEqual(collision_ctx.destination_paths["/downloads/shared/book [2].epub"].book_id, 2, "second renamed path ownership recorded")
assertEqual(collision_ctx.destination_paths["/downloads/shared/book [3].epub"].book_id, 3, "third renamed path ownership recorded")
Catalog.bulkReleaseStandby = function() end
Catalog.refreshOnDevice = function() end
Catalog.bookMode = function() return false end
Catalog.dashboardMode = function() return false end
Catalog:bulkFinish(collision_ctx)
assertEqual(shown_widget.title:find("Renamed path conflicts: 2", 1, true) ~= nil, true, "completion summary keeps conflict count")
assertEqual(shown_widget.title:find("Conflicting destinations were renamed with BookOrbit IDs.", 1, true) ~= nil, true, "completion summary explains renaming")
assertEqual(shown_widget.title:find(" -> ", 1, true), nil, "completion summary omits long path mappings")
print("bookorbit_catalog_bulk_download_test.lua: ok")