fix(koreader): align download dialog preview with device path

Ensure the catalog download dialog previews and resolves the destination folder and filename from devicePath when configured.

Fixes #752
This commit is contained in:
neon
2026-07-22 17:15:13 +00:00
parent 596284c389
commit 068de92b33
4 changed files with 273 additions and 20 deletions
@@ -38,6 +38,30 @@ local function sanitizeDevicePath(device_path)
return table.concat(segments, "/")
end
local function joinDownloadPath(download_dir, relative_path)
return (download_dir ~= "/" and download_dir or "") .. "/" .. relative_path
end
local function resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
local relative = sanitizeDevicePath(device_path)
if relative and not filename_override then return relative end
local parent = relative and relative:match("^(.*)/[^/]+$")
local safe_filename = util.getSafeFilename(filename .. "." .. string.lower(filetype or "bin"), download_dir)
return parent and parent ~= "" and parent .. "/" .. safe_filename or safe_filename
end
local function splitDownloadPreview(download_dir, relative_path, filetype)
local parent, filename = relative_path:match("^(.*)/([^/]+)$")
local folder = parent and parent ~= "" and joinDownloadPath(download_dir, parent) or download_dir
filename = filename or relative_path
local extension = "." .. string.lower(filetype or "bin")
if filename:sub(-#extension):lower() == extension then
filename = filename:sub(1, #filename - #extension)
end
return folder, filename
end
function CatalogDownload.install(Catalog)
function Catalog:showFileChoices(detail)
local files = self:supportedFiles(detail)
@@ -88,17 +112,18 @@ function CatalogDownload.install(Catalog)
return G_reader_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir")
end
function Catalog:getLocalDownloadPath(filename, filetype, device_path)
function Catalog:getLocalDownloadPath(filename, filetype, device_path, filename_override)
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
local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
local parent = relative:match("^(.*)/[^/]+$")
if parent and parent ~= "" then util.makePath(joinDownloadPath(download_dir, parent)) end
return joinDownloadPath(download_dir, relative)
end
function Catalog:getLocalDownloadPreview(filename, filetype, device_path, filename_override)
local download_dir = self:getCurrentDownloadDir()
local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
return splitDownloadPreview(download_dir, relative, filetype)
end
function Catalog:downloadDefaultFile(detail, file)
@@ -141,9 +166,13 @@ function CatalogDownload.install(Catalog)
UIManager:show(dialog)
end
function Catalog:showDownloadDialog(detail, file)
local filename = safeFilenameBase(detail)
function Catalog:showDownloadDialog(detail, file, initial_filename_override)
local filename = initial_filename_override or safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
local filename_overridden = initial_filename_override ~= nil
local folder
folder, filename = self:getLocalDownloadPreview(filename, filetype, file.devicePath, filename_overridden)
local function createTitle(path, name)
return T(_("Download folder:\n%1\n\nDownload filename:\n%2\n\nDownload file type:\n%3"),
@@ -152,14 +181,14 @@ function CatalogDownload.install(Catalog)
local dialog
dialog = ButtonDialog:new{
title = createTitle(self:getCurrentDownloadDir(), filename),
title = createTitle(folder, filename),
buttons = {
{
{
text = _("Download"),
callback = function()
UIManager:close(dialog)
local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath)
local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath, filename_overridden)
self:checkDownloadFile(local_path, detail, file)
end,
},
@@ -179,7 +208,7 @@ function CatalogDownload.install(Catalog)
end
G_reader_settings:saveSetting("download_dir", path)
UIManager:nextTick(function()
self:showDownloadDialog(detail, file)
self:showDownloadDialog(detail, file, filename_overridden and filename or nil)
end)
end,
}:chooseDir(self:getCurrentDownloadDir())
@@ -206,9 +235,12 @@ function CatalogDownload.install(Catalog)
is_enter_default = true,
callback = function()
local value = util.trim(input_dialog:getInputText() or "")
if value ~= "" then filename = value end
if value ~= "" then
filename_overridden = true
folder, filename = self:getLocalDownloadPreview(value, filetype, file.devicePath, true)
end
UIManager:close(input_dialog)
dialog:setTitle(createTitle(self:getCurrentDownloadDir(), filename))
dialog:setTitle(createTitle(folder, filename))
end,
},
},
+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.3.0"
local PLUGIN_VERSION = "1.3.1"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -55,8 +55,15 @@ package.loaded["logger"] = {
warn = function() end,
}
package.loaded["ffi/util"] = {
template = function(text)
return text
template = function(value, ...)
local result = value
for index = 1, select("#", ...) do
local replacement = tostring(select(index, ...))
result = result:gsub("%%" .. index, function()
return replacement
end)
end
return result
end,
}
local made_paths = {}
@@ -0,0 +1,214 @@
local shown_widget
local typed_filename
local made_paths = {}
local scheduled = {}
local function identity(value)
return value
end
package.loaded["ui/bidi"] = {
dirpath = identity,
filepath = identity,
}
package.loaded["ui/widget/buttondialog"] = {
new = function(_, opts)
function opts:setTitle(title)
self.title = title
end
return opts
end,
}
package.loaded["ui/widget/inputdialog"] = {
new = function(_, opts)
function opts:getInputText()
return typed_filename or self.input
end
function opts:onShowKeyboard() end
return opts
end,
}
package.loaded["ui/widget/confirmbox"] = package.loaded["ui/widget/buttondialog"]
package.loaded["ui/widget/infomessage"] = package.loaded["ui/widget/buttondialog"]
package.loaded["ui/widget/notification"] = package.loaded["ui/widget/buttondialog"]
package.loaded["ui/uimanager"] = {
show = function(_, widget)
shown_widget = widget
end,
close = function() end,
nextTick = function(_, callback)
table.insert(scheduled, callback)
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(value, ...)
local result = value
for index = 1, select("#", ...) do
local replacement = tostring(select(index, ...))
result = result:gsub("%%" .. index, function()
return replacement
end)
end
return result
end,
}
package.loaded["util"] = {
makePath = function(path)
table.insert(made_paths, path)
end,
getSafeFilename = function(filename)
return filename:gsub("[\\/:*?\"<>|]", "_")
end,
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
}
package.loaded["bookorbit_state"] = {}
package.loaded["gettext"] = identity
package.loaded["bookorbit_catalog_util"] = {
formatBytes = tostring,
safeFilenameBase = function(detail)
return detail.fallbackFilename
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local CatalogDownload = require("bookorbit_catalog_download")
local Catalog = {}
CatalogDownload.install(Catalog)
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
local function assertContains(value, expected, label)
if not value:find(expected, 1, true) then
error(string.format("%s: expected %q in %q", label, expected, value))
end
end
local download_dir = "/mnt/onboard/library"
G_reader_settings = {
readSetting = function(_, key)
if key == "download_dir" then return download_dir end
return nil
end,
saveSetting = function(_, key, value)
if key == "download_dir" then download_dir = value end
end,
}
local detail = {
id = 1,
title = "Annihilation",
fallbackFilename = "Annihilation - Jeff VanderMeer",
}
local file = {
id = 10,
format = "epub",
devicePath = "Series/Southern Reach/1.00 - Annihilation.epub",
}
local folder, filename = Catalog:getLocalDownloadPreview(detail.fallbackFilename, file.format, file.devicePath, false)
assertEqual(folder, "/mnt/onboard/library/Series/Southern Reach", "configured preview folder")
assertEqual(filename, "1.00 - Annihilation", "configured preview filename")
assertEqual(#made_paths, 0, "preview does not create directories")
local resolved = Catalog:getLocalDownloadPath(detail.fallbackFilename, file.format, file.devicePath)
assertEqual(resolved, "/mnt/onboard/library/Series/Southern Reach/1.00 - Annihilation.epub", "configured destination")
assertEqual(made_paths[1], "/mnt/onboard/library/Series/Southern Reach", "configured parent created")
made_paths = {}
resolved = Catalog:getLocalDownloadPath("Preferred/Name", "EPUB", file.devicePath, true)
assertEqual(resolved, "/mnt/onboard/library/Series/Southern Reach/Preferred_Name.epub", "filename override preserves configured folder")
assertEqual(made_paths[1], "/mnt/onboard/library/Series/Southern Reach", "override parent created")
made_paths = {}
resolved = Catalog:getLocalDownloadPath("Fallback", "epub", "../outside.epub")
assertEqual(resolved, "/mnt/onboard/library/Fallback.epub", "unsafe device path falls back inside download folder")
assertEqual(#made_paths, 0, "unsafe device path does not create directories")
resolved = Catalog:getLocalDownloadPath("Fallback", "epub", "./Series//Book.epub")
assertEqual(resolved, "/mnt/onboard/library/Series/Book.epub", "device path normalization remains unchanged")
download_dir = "/"
folder, filename = Catalog:getLocalDownloadPreview("Fallback", "epub", "Book.epub", false)
assertEqual(folder, "/", "root preview folder")
assertEqual(filename, "Book", "root preview filename")
assertEqual(Catalog:getLocalDownloadPath("Fallback", "epub", "Book.epub"), "/Book.epub", "root destination")
download_dir = "/mnt/onboard/library"
made_paths = {}
local downloaded_path
Catalog.checkDownloadFile = function(_, path)
downloaded_path = path
end
Catalog:showDownloadDialog(detail, file)
local dialog = shown_widget
assertContains(dialog.title, "Download folder:\n/mnt/onboard/library/Series/Southern Reach", "dialog shows resolved folder")
assertContains(dialog.title, "Download filename:\n1.00 - Annihilation", "dialog shows resolved filename")
assertEqual(#made_paths, 0, "opening dialog does not create directories")
dialog.buttons[1][1].callback()
assertEqual(downloaded_path, "/mnt/onboard/library/Series/Southern Reach/1.00 - Annihilation.epub", "dialog downloads previewed destination")
downloaded_path = nil
Catalog:showDownloadDialog(detail, file)
dialog = shown_widget
dialog.buttons[3][2].callback()
local input_dialog = shown_widget
assertEqual(input_dialog.input, "1.00 - Annihilation", "filename editor starts with resolved filename")
typed_filename = " Preferred/Name "
input_dialog.buttons[1][2].callback()
assertContains(dialog.title, "Download folder:\n/mnt/onboard/library/Series/Southern Reach", "override keeps resolved folder in dialog")
assertContains(dialog.title, "Download filename:\nPreferred_Name", "dialog previews sanitized override")
dialog.buttons[1][1].callback()
assertEqual(downloaded_path, "/mnt/onboard/library/Series/Southern Reach/Preferred_Name.epub", "filename editor changes downloaded filename")
typed_filename = nil
file.devicePath = nil
Catalog:showDownloadDialog(detail, file)
dialog = shown_widget
assertContains(dialog.title, "Download folder:\n/mnt/onboard/library", "legacy dialog keeps base folder")
assertContains(dialog.title, "Download filename:\nAnnihilation - Jeff VanderMeer", "legacy dialog keeps fallback filename")
dialog.buttons[1][1].callback()
assertEqual(downloaded_path, "/mnt/onboard/library/Annihilation - Jeff VanderMeer.epub", "legacy download remains unchanged")
file.devicePath = "Series/Southern Reach/1.00 - Annihilation.epub"
package.loaded["ui/downloadmgr"] = {
new = function(_, opts)
function opts:chooseDir()
self.onConfirm("/mnt/onboard/books")
end
return opts
end,
}
Catalog:showDownloadDialog(detail, file)
dialog = shown_widget
dialog.buttons[3][2].callback()
input_dialog = shown_widget
typed_filename = "Custom Name"
input_dialog.buttons[1][2].callback()
dialog.buttons[3][1].callback()
assertEqual(#scheduled, 1, "folder change queues refreshed dialog")
table.remove(scheduled, 1)()
dialog = shown_widget
assertContains(dialog.title, "Download folder:\n/mnt/onboard/books/Series/Southern Reach", "folder change refreshes resolved folder")
assertContains(dialog.title, "Download filename:\nCustom Name", "folder change preserves filename override")
dialog.buttons[1][1].callback()
assertEqual(downloaded_path, "/mnt/onboard/books/Series/Southern Reach/Custom Name.epub", "combined folder and filename changes match preview")
print("bookorbit_catalog_download_test.lua: ok")