From daeacef838dc9881952fb47f722043e462b18910 Mon Sep 17 00:00:00 2001 From: hius07 <62179190+hius07@users.noreply.github.com> Date: Sun, 2 Mar 2025 11:22:18 +0200 Subject: [PATCH] OPDS: group downloading (#13338) --- frontend/ui/data/onetime_migration.lua | 16 +- plugins/opds.koplugin/main.lua | 134 +++++++-- plugins/opds.koplugin/opdsbrowser.lua | 370 ++++++++++++++++++------- plugins/opds.koplugin/opdscatalog.lua | 77 ----- 4 files changed, 385 insertions(+), 212 deletions(-) delete mode 100644 plugins/opds.koplugin/opdscatalog.lua diff --git a/frontend/ui/data/onetime_migration.lua b/frontend/ui/data/onetime_migration.lua index c73fe0776..45c478a0b 100644 --- a/frontend/ui/data/onetime_migration.lua +++ b/frontend/ui/data/onetime_migration.lua @@ -12,7 +12,7 @@ local util = require("util") local _ = require("gettext") -- Date at which the last migration snippet was added -local CURRENT_MIGRATION_DATE = 20250207 +local CURRENT_MIGRATION_DATE = 20250302 -- Retrieve the date of the previous migration, if any local last_migration_date = G_reader_settings:readSetting("last_migration_date", 0) @@ -842,5 +842,19 @@ if last_migration_date < 20250207 then end end +-- 20250302, Move OPDS settings from settings.reader.ui to settings/opds.lua. +-- https://github.com/koreader/koreader/pull/13338 +if last_migration_date < 20250302 then + logger.info("Performing one-time migration for 20250302") + + local servers = G_reader_settings:readSetting("opds_servers") + if servers then + G_reader_settings:delSetting("opds_servers") + local settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/opds.lua") + settings:saveSetting("servers", servers) + settings:flush() + end +end + -- We're done, store the current migration date G_reader_settings:saveSetting("last_migration_date", CURRENT_MIGRATION_DATE) diff --git a/plugins/opds.koplugin/main.lua b/plugins/opds.koplugin/main.lua index 1b327094e..0b638b359 100644 --- a/plugins/opds.koplugin/main.lua +++ b/plugins/opds.koplugin/main.lua @@ -1,51 +1,129 @@ +local BD = require("ui/bidi") +local ConfirmBox = require("ui/widget/confirmbox") +local DataStorage = require("datastorage") local Dispatcher = require("dispatcher") +local LuaSettings = require("luasettings") +local OPDSBrowser = require("opdsbrowser") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") +local util = require("util") local _ = require("gettext") +local T = require("ffi/util").template local OPDS = WidgetContainer:extend{ name = "opds", - is_doc_only = false, + opds_settings_file = DataStorage:getSettingsDir() .. "/opds.lua", + settings = nil, + servers = nil, + downloads = nil, + default_servers = { + { + title = "Project Gutenberg", + url = "https://m.gutenberg.org/ebooks.opds/?format=opds", + }, + { + title = "Standard Ebooks", + url = "https://standardebooks.org/feeds/opds", + }, + { + title = "ManyBooks", + url = "http://manybooks.net/opds/index.php", + }, + { + title = "Internet Archive", + url = "https://bookserver.archive.org/", + }, + { + title = "textos.info (Spanish)", + url = "https://www.textos.info/catalogo.atom", + }, + { + title = "Gallica (French)", + url = "https://gallica.bnf.fr/opds", + }, + }, } +function OPDS:init() + self.settings = LuaSettings:open(self.opds_settings_file) + if next(self.settings.data) == nil then + self.updated = true -- first run, force flush + end + self.servers = self.settings:readSetting("servers", self.default_servers) + self.downloads = self.settings:readSetting("downloads", {}) + self:onDispatcherRegisterActions() + self.ui.menu:registerToMainMenu(self) +end + function OPDS:onDispatcherRegisterActions() Dispatcher:registerAction("opds_show_catalog", {category="none", event="ShowOPDSCatalog", title=_("OPDS Catalog"), filemanager=true,} ) end -function OPDS:init() - self:onDispatcherRegisterActions() - self.ui.menu:registerToMainMenu(self) -end - -function OPDS:showCatalog() - local OPDSCatalog = require("opdscatalog") - local filemanagerRefresh = function() self.ui:onRefresh() end - function OPDSCatalog:onClose() - UIManager:close(self) - local FileManager = require("apps/filemanager/filemanager") - if FileManager.instance then - filemanagerRefresh() - else - FileManager:showFiles(G_reader_settings:readSetting("download_dir")) - end - end - OPDSCatalog:showCatalog() -end - -function OPDS:onShowOPDSCatalog() - self:showCatalog() - return true -end - function OPDS:addToMainMenu(menu_items) - if not self.ui.view then + if self.ui.file_chooser then menu_items.opds = { text = _("OPDS catalog"), - callback = function() self:showCatalog() end + callback = function() + self:onShowOPDSCatalog() + end, } end end +function OPDS:onShowOPDSCatalog() + self.opds_browser = OPDSBrowser:new{ + servers = self.servers, + downloads = self.downloads, + title = _("OPDS catalog"), + is_popout = false, + is_borderless = true, + title_bar_fm_style = true, + _manager = self, + file_downloaded_callback = function(file) + self:showFileDownloadedDialog(file) + end, + close_callback = function() + if self.opds_browser.download_list then + self.opds_browser.download_list.close_callback() + end + UIManager:close(self.opds_browser) + self.opds_browser = nil + if self.last_downloaded_file then + if self.ui.file_chooser then + local pathname = util.splitFilePathName(self.last_downloaded_file) + self.ui.file_chooser:changeToPath(pathname, self.last_downloaded_file) + end + self.last_downloaded_file = nil + end + end, + } + UIManager:show(self.opds_browser) +end + +function OPDS:showFileDownloadedDialog(file) + self.last_downloaded_file = file + UIManager:show(ConfirmBox:new{ + text = T(_("File saved to:\n%1\nWould you like to read the downloaded book now?"), BD.filepath(file)), + ok_text = _("Read now"), + ok_callback = function() + self.last_downloaded_file = nil + self.opds_browser.close_callback() + if self.ui.document then + self.ui:switchDocument(file) + else + self.ui:openFile(file) + end + end, + }) +end + +function OPDS:onFlushSettings() + if self.updated then + self.settings:flush() + self.updated = nil + end +end + return OPDS diff --git a/plugins/opds.koplugin/opdsbrowser.lua b/plugins/opds.koplugin/opdsbrowser.lua index 9a061acb0..d7b13d726 100644 --- a/plugins/opds.koplugin/opdsbrowser.lua +++ b/plugins/opds.koplugin/opdsbrowser.lua @@ -9,6 +9,7 @@ local InputDialog = require("ui/widget/inputdialog") local Menu = require("ui/widget/menu") local MultiInputDialog = require("ui/widget/multiinputdialog") local NetworkMgr = require("ui/network/manager") +local Notification = require("ui/widget/notification") local OPDSParser = require("opdsparser") local OPDSPSE = require("opdspse") local UIManager = require("ui/uimanager") @@ -21,6 +22,7 @@ local socketutil = require("socketutil") local url = require("socket.url") local util = require("util") local _ = require("gettext") +local N_ = _.ngettext local T = require("ffi/util").template -- cache catalog parsed from feed xml @@ -30,37 +32,6 @@ local CatalogCache = Cache:new{ } local OPDSBrowser = Menu:extend{ - opds_servers = G_reader_settings:readSetting("opds_servers", { - { - title = "Project Gutenberg", - url = "https://m.gutenberg.org/ebooks.opds/?format=opds", - }, - { - title = "Standard Ebooks", - url = "https://standardebooks.org/feeds/opds", - }, - { - title = "Feedbooks", - url = "https://catalog.feedbooks.com/catalog/public_domain.atom", - }, - { - title = "ManyBooks", - url = "http://manybooks.net/opds/index.php", - }, - { - title = "Internet Archive", - url = "https://bookserver.archive.org/", - }, - { - title = "textos.info (Spanish)", - url = "https://www.textos.info/catalogo.atom", - }, - { - title = "Gallica (French)", - url = "https://gallica.bnf.fr/opds", - }, - }), - catalog_type = "application/atom%+xml", search_type = "application/opensearchdescription%+xml", search_template_type = "application/atom%+xml", @@ -89,19 +60,28 @@ function OPDSBrowser:init() Menu.init(self) -- call parent's init() end +local function buildRootEntry(server) + return { + text = server.title, + mandatory = server.username and "\u{f2c0}", + url = server.url, + username = server.username, + password = server.password, + raw_names = server.raw_names, -- use server raw filenames for download + searchable = server.url and server.url:match("%%s") and true or false, + } +end + -- Builds the root list of catalogs function OPDSBrowser:genItemTableFromRoot() - local item_table = {} - for _, server in ipairs(self.opds_servers) do - table.insert(item_table, { - text = server.title, - mandatory = server.username and "\u{f2c0}", - url = server.url, - username = server.username, - password = server.password, - raw_names = server.raw_names, -- use server raw filenames for download - searchable = server.url:match("%%s") and true or false, - }) + local item_table = { + { + text = _("Downloads"), + mandatory = #self.downloads, + }, + } + for _, server in ipairs(self.servers) do + table.insert(item_table, buildRootEntry(server)) end return item_table end @@ -193,7 +173,7 @@ function OPDSBrowser:addSubCatalog(item_url) UIManager:close(dialog) local fields = {name, item_url, self.root_catalog_username, self.root_catalog_password, self.root_catalog_raw_names} - self:editCatalogFromInput(fields, false, true) -- no init, stay in the subcatalog + self:editCatalogFromInput(fields, nil, true) -- no init, stay in the subcatalog end end, }, @@ -205,40 +185,37 @@ function OPDSBrowser:addSubCatalog(item_url) end -- Saves catalog properties from input dialog -function OPDSBrowser:editCatalogFromInput(fields, item, no_init) - local new_server - if item then -- edit old - for _, server in ipairs(self.opds_servers) do - if server.title == item.text and server.url == item.url then - new_server = server - break - end - end - else -- add new - new_server = {} +function OPDSBrowser:editCatalogFromInput(fields, item, no_refresh) + local new_server = { + title = fields[1], + url = fields[2]:match("^%a+://") and fields[2] or "http://" .. fields[2], + username = fields[3] ~= "" and fields[3] or nil, + password = fields[4] ~= "" and fields[4] or nil, + raw_names = fields[5], + } + local new_item = buildRootEntry(new_server) + local new_idx, itemnumber + if item then + new_idx = item.idx + itemnumber = -1 + else + new_idx = #self.servers + 2 + itemnumber = new_idx end - new_server.title = fields[1] - new_server.url = fields[2]:match("^%a+://") and fields[2] or "http://" .. fields[2] - new_server.username = fields[3] ~= "" and fields[3] or nil - new_server.password = fields[4] - new_server.raw_names = fields[5] - if not item then - table.insert(self.opds_servers, new_server) - end - if not no_init then - self:init() + self.servers[new_idx - 1] = new_server + self.item_table[new_idx] = new_item + if not no_refresh then + self:switchItemTable(nil, self.item_table, itemnumber) end + self._manager.updated = true end -- Deletes catalog from the root list function OPDSBrowser:deleteCatalog(item) - for i, server in ipairs(self.opds_servers) do - if server.title == item.text and server.url == item.url then - table.remove(self.opds_servers, i) - break - end - end - self:init() + table.remove(self.servers, item.idx - 1) + table.remove(self.item_table, item.idx) + self:switchItemTable(nil, self.item_table, -1) + self._manager.updated = true end -- Fetches feed from server @@ -642,10 +619,20 @@ function OPDSBrowser:showDownloads(item) table.insert(download_buttons, { text = text .. "\u{2B07}", -- append DOWNWARDS BLACK ARROW callback = function() - local file = filename and filename .. "." .. string.lower(filetype) - or self:getServerFileName(acquisition.href) - self:downloadFile(file, acquisition.href) UIManager:close(self.download_dialog) + local local_path = self:getLocalDownloadPath(filename, filetype, acquisition.href) + self:downloadFile(local_path, acquisition.href, self._manager.file_downloaded_callback) + end, + hold_callback = function() + UIManager:close(self.download_dialog) + table.insert(self.downloads, { + file = self:getLocalDownloadPath(filename, filetype, acquisition.href), + url = acquisition.href, + info = type(item.content) == "string" and util.htmlToPlainTextIfHtml(item.content), + catalog = self.root_catalog_title, + }) + self._manager.updated = true + Notification:notify(_("Book added to download list")) end, }) end @@ -756,20 +743,24 @@ function OPDSBrowser.getCurrentDownloadDir() return G_reader_settings:readSetting("download_dir") or G_reader_settings:readSetting("lastdir") end --- Downloads a book (with "File already exists" dialog) -function OPDSBrowser:downloadFile(filename, remote_url) - local download_dir = self.getCurrentDownloadDir() - +function OPDSBrowser:getLocalDownloadPath(filename, filetype, remote_url) + local download_dir = OPDSBrowser.getCurrentDownloadDir() + filename = filename and filename .. "." .. filetype:lower() or self:getServerFileName(remote_url) filename = util.getSafeFilename(filename, download_dir) - local local_path = (download_dir ~= "/" and download_dir or "") .. '/' .. filename - local_path = util.fixUtf8(local_path, "_") + filename = (download_dir ~= "/" and download_dir or "") .. '/' .. filename + return util.fixUtf8(filename, "_") +end +-- Downloads a book (with "File already exists" dialog) +function OPDSBrowser:downloadFile(local_path, remote_url, caller_callback) + local ask_to_overwrite = caller_callback ~= nil -- single file downloading + local code local function download() - UIManager:scheduleIn(1, function() +-- UIManager:scheduleIn(1, function() logger.dbg("Downloading file", local_path, "from", remote_url) local parsed = url.parse(remote_url) - local code, headers, status + local headers, status if parsed.scheme == "http" or parsed.scheme == "https" then socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT) code, headers, status = socket.skip(1, http.request { @@ -790,7 +781,9 @@ function OPDSBrowser:downloadFile(filename, remote_url) if code == 200 then logger.dbg("File downloaded to", local_path) - self.file_downloaded_callback(local_path) + if caller_callback then + caller_callback(local_path) + end elseif code == 302 and remote_url:match("^https") and headers.location:match("^http[^s]") then util.removeFile(local_path) UIManager:show(InfoMessage:new{ @@ -807,7 +800,7 @@ function OPDSBrowser:downloadFile(filename, remote_url) status or code or "network unreachable"), }) end - end) +-- end) UIManager:show(InfoMessage:new{ text = _("Downloading may take several minutes…"), @@ -815,7 +808,7 @@ function OPDSBrowser:downloadFile(filename, remote_url) }) end - if lfs.attributes(local_path) then + if ask_to_overwrite and lfs.attributes(local_path) then UIManager:show(ConfirmBox:new{ text = T(_("The file %1 already exists. Do you want to overwrite it?"), BD.filepath(local_path)), ok_text = _("Overwrite"), @@ -826,6 +819,7 @@ function OPDSBrowser:downloadFile(filename, remote_url) else download() end + return code == 200 end -- Menu action on item tap (Download a book / Show subcatalog / Search in catalog) @@ -835,6 +829,12 @@ function OPDSBrowser:onMenuSelect(item) self:showDownloads(item) else -- catalog or Search item if #self.paths == 0 then -- root list + if item.idx == 1 then + if #self.downloads > 0 then + self:showDownloadList() + end + return true + end self.root_catalog_title = item.text self.root_catalog_username = item.username self.root_catalog_password = item.password @@ -858,7 +858,7 @@ end -- Menu action on item long-press (dialog Edit / Delete catalog) function OPDSBrowser:onMenuHold(item) - if #self.paths > 0 then return end -- not root list + if #self.paths > 0 or item.idx == 1 then return true end -- not root list or Downloads item local dialog dialog = ButtonDialog:new{ title = item.text, @@ -894,31 +894,22 @@ end -- Menu action on return-arrow tap (go to one-level upper catalog) function OPDSBrowser:onReturn() - if #self.paths > 0 then -- not root list - table.remove(self.paths) - local path = self.paths[#self.paths] - if path then - -- return to last path - self.catalog_title = path.title - self:updateCatalog(path.url, true) - else - -- return to root path, we simply reinit opdsbrowser - self:init() - end + table.remove(self.paths) + local path = self.paths[#self.paths] + if path then + -- return to last path + self.catalog_title = path.title + self:updateCatalog(path.url, true) + else + -- return to root path, we simply reinit opdsbrowser + self:init() end return true end --- Menu action on return-arrow long-press (go to the catalog home page) +-- Menu action on return-arrow long-press (return to root path) function OPDSBrowser:onHoldReturn() - if #self.paths > 1 then -- not catalog home page - local path = self.paths[1] - for i = #self.paths, 2, -1 do - table.remove(self.paths) - end - self.catalog_title = path.title - self:updateCatalog(path.url, true) - end + self:init() return true end @@ -944,4 +935,171 @@ function OPDSBrowser:onNextPage(fill_only) return true end +function OPDSBrowser:showDownloadList() + self.download_list = Menu:new{ + covers_fullscreen = true, + is_borderless = true, + is_popout = false, + title_bar_fm_style = true, + onMenuSelect = self.showDownloadListItemDialog, + _manager = self, + } + self.download_list.close_callback = function() + UIManager:close(self.download_list) + self.download_list = nil + if self.download_list_updated then + self.download_list_updated = nil + self.item_table[1].mandatory = #self.downloads + self:updateItems(1, true) + end + end + self:updateDownloadListItemTable() + UIManager:show(self.download_list) +end + +function OPDSBrowser:updateDownloadListItemTable(item_table) + if item_table == nil then + item_table = {} + for i, item in ipairs(self.downloads) do + item_table[i] = { + text = item.file:gsub(".*/", ""), + mandatory = item.catalog, + } + end + end + local title = T(_("Downloads (%1)"), #item_table) + self.download_list:switchItemTable(title, item_table) +end + +function OPDSBrowser:showDownloadListItemDialog(item) + local dl_item = self._manager.downloads[item.idx] + local textviewer + local function remove_item() + textviewer:onClose() + table.remove(self._manager.downloads, item.idx) + table.remove(self.item_table, item.idx) + self._manager:updateDownloadListItemTable(self.item_table) + self._manager.download_list_updated = true + self._manager._manager.updated = true + end + local buttons_table = { + { + { + text = _("Remove"), + callback = function() + remove_item() + end, + }, + { + text = _("Download"), + callback = function() + local function file_downloaded_callback(local_path) + remove_item() + self._manager.file_downloaded_callback(local_path) + end + self._manager:downloadFile(dl_item.file, dl_item.url, file_downloaded_callback) + end, + }, + }, + {}, -- separator + { + { + text = _("Remove all"), + callback = function() + textviewer:onClose() + UIManager:show(ConfirmBox:new{ + text = _("Remove all downloads?"), + ok_text = _("Remove"), + ok_callback = function() + for i in ipairs(self._manager.downloads) do + self._manager.downloads[i] = nil + end + self._manager.download_list_updated = true + self._manager._manager.updated = true + self:close_callback() + end, + }) + end, + }, + { + text = _("Download all"), + callback = function() + textviewer:onClose() + UIManager:show(ConfirmBox:new{ + text = _("Download all books?\nExisting files will be overwritten."), + ok_text = _("Download"), + ok_callback = function() + local Trapper = require("ui/trapper") + Trapper:wrap(function() + self._manager:downloadDownloadList() + end) + end, + }) + end, + }, + }, + } + local TextBoxWidget = require("ui/widget/textboxwidget") + local text = table.concat({ + TextBoxWidget.PTF_HEADER, + TextBoxWidget.PTF_BOLD_START, _("Folder"), TextBoxWidget.PTF_BOLD_END, "\n", + util.splitFilePathName(dl_item.file), "\n", "\n", + TextBoxWidget.PTF_BOLD_START, _("File"), TextBoxWidget.PTF_BOLD_END, "\n", + item.text, "\n", "\n", + TextBoxWidget.PTF_BOLD_START, _("Description"), TextBoxWidget.PTF_BOLD_END, "\n", + dl_item.info, + }) + local TextViewer = require("ui/widget/textviewer") + textviewer = TextViewer:new{ + title = dl_item.catalog, + text = text, + text_type = "book_info", + buttons_table = buttons_table, + } + UIManager:show(textviewer) + return true +end + +function OPDSBrowser:downloadDownloadList() + local info = InfoMessage:new{ text = _("Downloading… (tap to cancel)") } + UIManager:show(info) + UIManager:forceRePaint() + local Trapper = require("ui/trapper") + local completed, downloaded = Trapper:dismissableRunInSubprocess(function() + local dl = {} + for _, item in ipairs(self.downloads) do + if self:downloadFile(item.file, item.url) then + dl[item.file] = true + end + end + return dl + end, info) + if completed then + UIManager:close(info) + end + local dl_count = #self.downloads + for i = dl_count, 1, -1 do + local item = self.downloads[i] + if downloaded and downloaded[item.file] then + table.remove(self.downloads, i) + else -- if subprocess has been interrupted, check for the downloaded file + local attr = lfs.attributes(item.file) + if attr then + if attr.size > 0 then + table.remove(self.downloads, i) + else -- incomplete download + os.remove(item.file) + end + end + end + end + dl_count = dl_count - #self.downloads + if dl_count > 0 then + self:updateDownloadListItemTable() + self.download_list_updated = true + self._manager.updated = true + UIManager:show(InfoMessage:new{ text = T(N_("1 book downloaded", "%1 books downloaded", dl_count), dl_count) }) + end +end + return OPDSBrowser diff --git a/plugins/opds.koplugin/opdscatalog.lua b/plugins/opds.koplugin/opdscatalog.lua deleted file mode 100644 index 07bdcb2a8..000000000 --- a/plugins/opds.koplugin/opdscatalog.lua +++ /dev/null @@ -1,77 +0,0 @@ -local BD = require("ui/bidi") -local Blitbuffer = require("ffi/blitbuffer") -local ConfirmBox = require("ui/widget/confirmbox") -local FrameContainer = require("ui/widget/container/framecontainer") -local OPDSBrowser = require("opdsbrowser") -local UIManager = require("ui/uimanager") -local WidgetContainer = require("ui/widget/container/widgetcontainer") -local logger = require("logger") -local _ = require("gettext") -local Screen = require("device").screen -local T = require("ffi/util").template - -local OPDSCatalog = WidgetContainer:extend{ - title = _("OPDS Catalog"), -} - -function OPDSCatalog:init() - local opds_browser = OPDSBrowser:new{ - title = self.title, - show_parent = self, - is_popout = false, - is_borderless = true, - close_callback = function() return self:onClose() end, - file_downloaded_callback = function(downloaded_file) - UIManager:show(ConfirmBox:new{ - text = T(_("File saved to:\n%1\nWould you like to read the downloaded book now?"), - BD.filepath(downloaded_file)), - ok_text = _("Read now"), - cancel_text = _("Read later"), - ok_callback = function() - local Event = require("ui/event") - UIManager:broadcastEvent(Event:new("SetupShowReader")) - - self:onClose() - - local ReaderUI = require("apps/reader/readerui") - ReaderUI:showReader(downloaded_file) - end - }) - end, - } - - self[1] = FrameContainer:new{ - padding = 0, - bordersize = 0, - background = Blitbuffer.COLOR_WHITE, - opds_browser, - } -end - -function OPDSCatalog:onShow() - UIManager:setDirty(self, function() - return "ui", self[1].dimen -- i.e., FrameContainer - end) -end - -function OPDSCatalog:onCloseWidget() - UIManager:setDirty(nil, function() - return "ui", self[1].dimen - end) -end - -function OPDSCatalog:showCatalog() - logger.dbg("show OPDS catalog") - UIManager:show(OPDSCatalog:new{ - dimen = Screen:getSize(), - covers_fullscreen = true, -- hint for UIManager:_repaint() - }) -end - -function OPDSCatalog:onClose() - logger.dbg("close OPDS catalog") - UIManager:close(self) - return true -end - -return OPDSCatalog