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