You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/plugins/opds.koplugin/opdsbrowser.lua

1117 lines
41 KiB
Lua

local BD = require("ui/bidi")
local ButtonDialog = require("ui/widget/buttondialog")
local ButtonDialogTitle = require("ui/widget/buttondialogtitle")
local Cache = require("cache")
local ConfirmBox = require("ui/widget/confirmbox")
local DocumentRegistry = require("document/documentregistry")
local Font = require("ui/font")
local ImageViewer = require("ui/widget/imageviewer")
local InfoMessage = require("ui/widget/infomessage")
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 OPDSParser = require("opdsparser")
local RenderImage = require("ui/renderimage")
local Screen = require("device").screen
local UIManager = require("ui/uimanager")
local http = require("socket.http")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local ltn12 = require("ltn12")
local socket = require("socket")
local socketutil = require("socketutil")
local url = require("socket.url")
local util = require("util")
local _ = require("gettext")
local T = require("ffi/util").template
-- cache catalog parsed from feed xml
local CatalogCache = Cache:new{
-- Make it 20 slots, with no storage space constraints
slots = 20,
}
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",
},
}),
calibre_name = _("Local calibre library"),
calibre_opds = G_reader_settings:readSetting("calibre_opds", {}),
catalog_type = "application/atom%+xml",
search_type = "application/opensearchdescription%+xml",
search_template_type = "application/atom%+xml",
acquisition_rel = "^http://opds%-spec%.org/acquisition",
image_rel = "http://opds-spec.org/image",
thumbnail_rel = "http://opds-spec.org/image/thumbnail",
stream_rel = "http://vaemendis.net/opds-pse/stream",
width = Screen:getWidth(),
height = Screen:getHeight(),
no_title = false,
parent = nil,
}
function OPDSBrowser:init()
self.item_table = self:genItemTableFromRoot()
self.catalog_title = nil
self.title_bar_left_icon = "plus"
self.onLeftButtonTap = function()
self:addNewCatalog()
end
Menu.init(self) -- call parent's init()
end
-- This function is a callback fired from the new
-- catalog dialog, 'addNewCatalog'.
function OPDSBrowser:addServerFromInput(fields)
logger.dbg("New OPDS catalog input:", fields)
local new_server = {
title = fields[1],
url = (fields[2]:match("^%a+://") and fields[2] or "http://" .. fields[2]),
searchable = (fields[2]:match("%%s") and true or false),
username = fields[3] ~= "" and fields[3] or nil,
-- Allow empty passwords
password = fields[4],
}
table.insert(self.opds_servers, new_server)
self:init()
end
-- This function is a callback fired from the Calibre input
-- dialog 'editCalibreServer'.
function OPDSBrowser:editCalibreFromInput(fields)
logger.dbg("Edit calibre server input:", fields)
if fields[1] then
self.calibre_opds.host = fields[1]
end
if tonumber(fields[2]) then
self.calibre_opds.port = fields[2]
end
if fields[3] and fields[3] ~= "" then
self.calibre_opds.username = fields[3]
else
self.calibre_opds.username = nil
end
if fields[4] then
self.calibre_opds.password = fields[4]
else
self.calibre_opds.password = nil
end
self:init()
end
-- This function shows a dialog with input fields
-- for entering information for an OPDS catalog.
function OPDSBrowser:addNewCatalog()
self.add_server_dialog = MultiInputDialog:new{
title = _("Add OPDS catalog"),
fields = {
{
text = "",
hint = _("Catalog name"),
},
{
text = "",
hint = _("Catalog URL"),
},
{
text = "",
hint = _("Username (optional)"),
},
{
text = "",
hint = _("Password (optional)"),
text_type = "password",
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
self.add_server_dialog:onClose()
UIManager:close(self.add_server_dialog)
end
},
{
text = _("Add"),
callback = function()
self.add_server_dialog:onClose()
UIManager:close(self.add_server_dialog)
self:addServerFromInput(self.add_server_dialog:getFields())
end
},
},
},
}
UIManager:show(self.add_server_dialog)
self.add_server_dialog:onShowKeyboard()
end
-- This function shows a dialog to the user with input fields
-- for setting Calibre server information.
-- (I think that the Calibre stuff could be moved to a separate file.)
function OPDSBrowser:editCalibreServer()
self.add_server_dialog = MultiInputDialog:new{
title = _("Edit local calibre host and port"),
fields = {
{
--- @todo get IP address of current device
text = self.calibre_opds.host or "192.168.1.1",
hint = _("calibre host"),
},
{
text = self.calibre_opds.port and tostring(self.calibre_opds.port) or "8080",
hint = _("calibre port"),
},
{
text = self.calibre_opds.username or "",
hint = _("Username (optional)"),
},
{
text = self.calibre_opds.password or "",
hint = _("Password (optional)"),
text_type = "password",
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
self.add_server_dialog:onClose()
UIManager:close(self.add_server_dialog)
end
},
{
text = _("Apply"),
callback = function()
self.add_server_dialog:onClose()
UIManager:close(self.add_server_dialog)
self:editCalibreFromInput(self.add_server_dialog:getFields())
end
},
},
},
}
UIManager:show(self.add_server_dialog)
self.add_server_dialog:onShowKeyboard()
end
-- This function creates the "main menu" for the plugin,
-- wherein the user is shown the default servers, their
-- custom servers, and an item to allow them to add more of their
-- own servers.
function OPDSBrowser:genItemTableFromRoot()
local item_table = {}
-- Loop through the default servers and add them
-- to the item table.
for _, server in ipairs(self.opds_servers) do
table.insert(item_table, {
text = server.title,
content = server.subtitle,
url = server.url,
username = server.username,
password = server.password,
deletable = true,
editable = true,
searchable = server.searchable,
})
end
-- Handle the Calibre server. If it's not set, then place
-- an item that would prompt the user to enter their Calibre settings.
if not self.calibre_opds.host or not self.calibre_opds.port then
-- Here's where we allow the Calibre server to be set.
table.insert(item_table, {
text = self.calibre_name,
callback = function()
self:editCalibreServer()
end,
deletable = false,
})
else
-- Here's where we show the existing Calibre server with
-- the login details stored on the device.
table.insert(item_table, {
text = self.calibre_name,
url = string.format("http://%s:%d/opds",
self.calibre_opds.host, self.calibre_opds.port),
username = self.calibre_opds.username,
password = self.calibre_opds.password,
editable = true,
deletable = false,
searchable = false,
})
end
return item_table
end
function OPDSBrowser:fetchFeed(item_url, username, password, method)
local sink = {}
socketutil:set_timeout(
socketutil.LARGE_BLOCK_TIMEOUT,
socketutil.LARGE_TOTAL_TIMEOUT
)
-- Prepare the request to send to the server.
local request = {
url = item_url,
method = method and method or "GET",
-- Explicitly specify that we don't support compressed content.
-- Some servers will still break RFC2616 14.3 and send crap instead.
headers = {
["Accept-Encoding"] = "identity",
},
sink = ltn12.sink.table(sink),
user = username,
password = password,
}
logger.dbg("Request:", request)
-- Fire off the request and wait to see what we get back.
local code, headers, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
-- Check the response and raise error message when network is unavailable.
if headers == nil then
error(status or code or "network unreachable")
end
-- Below are numerous if cases to handle different response codes.
if code == 200 then
-- 200 means the request succeeded.
-- If the method sent was HEAD, then we're probably checking for
-- an update and therefore only interested in the last-modified
-- time of the resource (who needs a body when you have a head?).
if method == "HEAD" then
if headers["last-modified"] then
return headers["last-modified"]
else
return
end
end
-- If the method sent was not HEAD, then we are interested in
-- the payload of the request. We'll add that to a table below
-- and return that as the result of this function.
local xml = table.concat(sink)
-- Obviously, check to see if the payload exists.
if xml ~= "" then
return xml
end
elseif method == "HEAD" then
-- Don't show error messages when we check headers only.
return
elseif code == 301 then -- Page has permanently moved
UIManager:show(InfoMessage:new{
text = T(_("The catalog has been permanently moved. Please update catalog URL to '%1'."),
BD.url(headers.location)),
})
elseif code == 302
and item_url:match("^https")
and headers.location:match("^http[^s]") then -- Page is redirecting
UIManager:show(InfoMessage:new{
text = T(_("Insecure HTTPS → HTTP downgrade attempted by redirect from:\n\n'%1'\n\nto\n\n'%2'.\n\nPlease inform the server administrator that many clients disallow this because it could be a downgrade attack."),
BD.url(item_url),
BD.url(headers.location)),
icon = "notice-warning",
})
elseif code == 401 then -- Not authorized
UIManager:show(InfoMessage:new{
text = T(_("Authentication required for catalog. Please add a username and password.")),
})
elseif code == 403 then -- Authorization attemp failed
UIManager:show(InfoMessage:new{
text = T(_("Failed to authenticate. Please check your username and password.")),
})
elseif code == 404 then -- Page not found
UIManager:show(InfoMessage:new{
text = T(_("Catalog not found.")),
})
elseif code == 406 then -- Server cannot fulfil our request
UIManager:show(InfoMessage:new{
text = T(_("Cannot get catalog. Server refuses to serve uncompressed content.")),
})
else
-- This block handles all other requests and supplies the user with a generic
-- error message and no more information than the code.
UIManager:show(InfoMessage:new{
text = T(_("Cannot get catalog. Server response status: %1."), status or code),
})
end
end
function OPDSBrowser:parseFeed(item_url, username, password)
local feed_last_modified = self:fetchFeed(item_url, username, password, "HEAD")
local hash = "opds|catalog|" .. item_url
if feed_last_modified then
hash = hash .. "|" .. feed_last_modified
end
local feed = CatalogCache:check(hash)
if feed then
logger.dbg("Cache hit for", hash)
else
logger.dbg("Cache miss for", hash)
feed = self:fetchFeed(item_url, username, password)
if feed then
logger.dbg("Caching", hash)
CatalogCache:insert(hash, feed)
end
end
if feed then
return OPDSParser:parse(feed)
end
end
function OPDSBrowser:getCatalog(item_url, username, password)
local ok, catalog = pcall(self.parseFeed, self, item_url, username, password)
if not ok and catalog then
logger.info("Cannot get catalog info from", item_url, catalog)
UIManager:show(InfoMessage:new{
text = T(_("Cannot get catalog info from %1"), (item_url and BD.url(item_url) or "nil")),
})
return
end
if ok and catalog then
return catalog
end
end
function OPDSBrowser:genItemTableFromURL(item_url, username, password)
local catalog = self:getCatalog(item_url, username, password)
return self:genItemTableFromCatalog(catalog, item_url, username, password)
end
function OPDSBrowser:getSearchTemplate(osd_url, username, password)
-- parse search descriptor
local search_descriptor = self:parseFeed(osd_url, username, password)
if search_descriptor and search_descriptor.OpenSearchDescription and search_descriptor.OpenSearchDescription.Url then
for _, candidate in ipairs(search_descriptor.OpenSearchDescription.Url) do
if candidate.type and candidate.template and candidate.type:find(self.search_template_type) then
return candidate.template:gsub("{searchTerms}", "%%s")
end
end
end
end
function OPDSBrowser:genItemTableFromCatalog(catalog, item_url, username, password)
local item_table = {}
if not catalog then
return item_table
end
local feed = catalog.feed or catalog
local function build_href(href)
return url.absolute(item_url, href)
end
local hrefs = {}
if feed.link then
for _, link in ipairs(feed.link) do
if link.type ~= nil then
if link.type:find(self.catalog_type) then
if link.rel and link.href then
hrefs[link.rel] = build_href(link.href)
end
end
if link.type:find(self.search_type) then
if link.href then
local stpl = self:getSearchTemplate(build_href(link.href), username, password)
-- The OpenSearchDescription/Url template field might *also* be a relative path...
stpl = build_href(stpl)
-- insert the search item
local item = {}
item.acquisitions = {}
item.text = "Search"
item.callback = function()
self:browseSearchable(stpl, username, password)
end
table.insert(item_table, item)
end
end
end
end
end
item_table.hrefs = hrefs
if username then
item_table.username = username
end
if password then
item_table.password = password
end
if not feed.entry then
if #hrefs == 0 then
UIManager:show(InfoMessage:new{
text = _("Failed to parse the catalog."),
})
end
return item_table
end
for _, entry in ipairs(feed.entry) do
local item = {}
item.acquisitions = {}
if entry.link then
for _, link in ipairs(entry.link) do
if link.type and link.type:find(self.catalog_type)
and (not link.rel
or link.rel == "subsection"
or link.rel == "http://opds-spec.org/subsection"
or link.rel == "http://opds-spec.org/sort/popular"
or link.rel == "http://opds-spec.org/sort/new") then
item.url = build_href(link.href)
end
-- Some catalogs do not use the rel attribute to denote
-- a publication. Arxiv uses title. Specifically, it uses
-- a title attribute that contains pdf. (title="pdf")
if link.rel or link.title then
if link.rel:match(self.acquisition_rel) then
table.insert(item.acquisitions, {
type = link.type,
href = build_href(link.href),
title = link.title,
})
elseif link.rel == self.stream_rel then
table.insert(item.acquisitions, {
type = link.type,
href = build_href(link.href),
title = link.title,
stream = true,
count = tonumber(link["pse:count"] or "1"),
})
elseif link.rel == self.thumbnail_rel then
item.thumbnail = build_href(link.href)
elseif link.rel == self.image_rel then
item.image = build_href(link.href)
end
-- This statement grabs the catalog items that are
-- indicated by title="pdf" or whose type is
-- "application/pdf"
if link.title == "pdf" or link.type == "application/pdf"
and link.rel ~= "subsection" then
-- Check for the presence of the pdf suffix and add it
-- if it's missing.
local href = link.href
local filetype = util.getFileNameSuffix(link.href)
if filetype ~= "pdf" then
href = href .. ".pdf"
end
table.insert(item.acquisitions, {
type = link.title,
href = build_href(href),
})
end
end
end
end
local title = "Unknown"
if type(entry.title) == "string" then
title = entry.title
elseif type(entry.title) == "table" then
if type(entry.title.type) == "string" and entry.title.div ~= "" then
title = entry.title.div
end
end
if title == "Unknown" then
logger.info("Cannot handle title", entry.title)
end
item.text = title
local author = "Unknown Author"
if type(entry.author) == "table" and entry.author.name then
author = entry.author.name
if type(author) == "table" then
if #author > 0 then
author = table.concat(author, ", ")
else
-- we may get an empty table on https://gallica.bnf.fr/opds
author = nil
end
end
if author then
item.text = title .. " - " .. author
end
end
item.title = title
item.author = author
item.id = entry.id
item.content = entry.content
item.updated = entry.updated
if username then
item.username = username
end
if password then
item.password = password
end
table.insert(item_table, item)
end
return item_table
end
function OPDSBrowser:updateCatalog(item_url, username, password)
local menu_table = self:genItemTableFromURL(item_url, username, password)
if #menu_table > 0 then
self:switchItemTable(self.catalog_title, menu_table)
self:setTitleBarLeftIcon("home")
self.onLeftButtonTap = function()
self:init()
end
if self.page_num <= 1 then
self:onNext()
end
return true
end
end
function OPDSBrowser:appendCatalog(item_url, username, password)
local new_table = self:genItemTableFromURL(item_url, username, password)
if #new_table == 0 then return false end
for _, item in ipairs(new_table) do
table.insert(self.item_table, item)
end
self.item_table.hrefs = new_table.hrefs
self:switchItemTable(self.catalog_title, self.item_table, -1)
return true
end
function OPDSBrowser.getCurrentDownloadDir()
local lastdir = G_reader_settings:readSetting("lastdir")
return G_reader_settings:readSetting("download_dir") or lastdir
end
function OPDSBrowser:downloadFile(item, filename, remote_url)
-- Download to user selected folder or last opened folder.
local download_dir = self.getCurrentDownloadDir()
filename = util.getSafeFilename(filename, download_dir)
local local_path = (download_dir ~= "/" and download_dir or "") .. '/' .. filename
local_path = util.fixUtf8(local_path, "_")
local function download()
UIManager:scheduleIn(1, function()
logger.dbg("Downloading file", local_path, "from", remote_url)
local parsed = url.parse(remote_url)
local code, 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 {
url = remote_url,
headers = {
["Accept-Encoding"] = "identity",
},
sink = ltn12.sink.file(io.open(local_path, "w")),
user = item.username,
password = item.password,
})
socketutil:reset_timeout()
else
UIManager:show(InfoMessage:new {
text = T(_("Invalid protocol:\n%1"), parsed.scheme),
})
end
if code == 200 then
logger.dbg("File downloaded to", local_path)
if self.file_downloaded_callback then
self.file_downloaded_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{
text = T(_("Insecure HTTPS → HTTP downgrade attempted by redirect from:\n\n'%1'\n\nto\n\n'%2'.\n\nPlease inform the server administrator that many clients disallow this because it could be a downgrade attack."), BD.url(remote_url), BD.url(headers.location)),
icon = "notice-warning",
})
else
util.removeFile(local_path)
logger.dbg("OPDSBrowser:downloadFile: Request failed:", status or code)
logger.dbg("OPDSBrowser:downloadFile: Response headers:", headers)
UIManager:show(InfoMessage:new {
text = T(_("Could not save file to:\n%1\n%2"),
BD.filepath(local_path),
status or code or "network unreachable"),
})
end
end)
UIManager:show(InfoMessage:new{
text = _("Downloading may take several minutes…"),
timeout = 1,
})
end
if lfs.attributes(local_path, "mode") == "file" 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"),
ok_callback = function()
download()
end,
})
else
download()
end
end
function OPDSBrowser:streamPages(item, remote_url, count)
local page_table = {image_disposable = true}
setmetatable(page_table, {__index = function (_, key)
if type(key) ~= "number" then
local error_bb = RenderImage:renderImageFile("resources/koreader.png", false)
return error_bb
else
local index = key - 1
local page_url = remote_url:gsub("{pageNumber}", tostring(index))
page_url = page_url:gsub("{maxWidth}", tostring(Screen:getWidth()))
local page_data = {}
logger.dbg("Streaming page from", page_url)
local parsed = url.parse(page_url)
local code, 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 {
url = page_url,
headers = {
["Accept-Encoding"] = "identity",
},
sink = ltn12.sink.table(page_data),
user = item.username,
password = item.password,
})
socketutil:reset_timeout()
else
UIManager:show(InfoMessage:new {
text = T(_("Invalid protocol:\n%1"), parsed.scheme),
})
end
local data = table.concat(page_data)
if code == 200 then
local page_bb = RenderImage:renderImageData(data, #data, false)
or RenderImage:renderImageFile("resources/koreader.png", false)
return page_bb
else
logger.dbg("OPDSBrowser:streamPages: Request failed:", status or code)
logger.dbg("OPDSBrowser:streamPages: Response headers:", headers)
local error_bb = RenderImage:renderImageFile("resources/koreader.png", false)
return error_bb
end
end
end})
local viewer = ImageViewer:new{
image = page_table,
fullscreen = true,
with_title_bar = false,
image_disposable = false, -- instead set page_table image_disposable to true
}
-- in Lua 5.2 we could override __len, but this works too
viewer._images_list_nb = count
UIManager:show(viewer)
end
function OPDSBrowser:showDownloads(item)
local acquisitions = item.acquisitions
local filename = item.title
if item.author then
filename = item.author .. " - " .. filename
end
local filename_orig = filename
local function createTitle(path, file) -- title for ButtonDialogTitle
return T(_("Download folder:\n%1\n\nDownload filename:\n%2\n\nDownload file type:"),
BD.dirpath(path), file)
end
local buttons = {} -- buttons for ButtonDialogTitle
local type_buttons = {} -- file type download buttons
for i = 1, #acquisitions do -- filter out unsupported file types
local acquisition = acquisitions[i]
if acquisition.stream then
-- this is an OPDS PSE stream
table.insert(type_buttons, {
text = _("Page stream") .. "\u{2B0C}", -- append LEFT RIGHT BLACK ARROW
callback = function()
self:streamPages(item, acquisition.href, acquisition.count)
UIManager:close(self.download_dialog)
end,
})
else
-- this is some other type of file
local filetype = util.getFileNameSuffix(acquisition.href)
logger.dbg("Filetype for download is", filetype)
if not DocumentRegistry:hasProvider("dummy." .. filetype) then
filetype = nil
end
if not filetype and DocumentRegistry:hasProvider(nil, acquisition.type) then
filetype = DocumentRegistry:mimeToExt(acquisition.type)
end
if filetype then -- supported file type
local text = acquisition.title and acquisition.title or string.upper(filetype)
table.insert(type_buttons, {
text = text .. "\u{2B07}", -- append DOWNWARDS BLACK ARROW
callback = function()
self:downloadFile(item, filename .. "." .. string.lower(filetype), acquisition.href)
UIManager:close(self.download_dialog)
end,
})
end
end
end
if (#type_buttons % 2 == 1) then -- we need even number of type buttons
table.insert(type_buttons, {text = ""})
end
for i = 2, #type_buttons, 2 do
table.insert(buttons, {type_buttons[i - 1], type_buttons[i]}) -- type buttons, two in a row
end
table.insert(buttons, {}) -- separator
table.insert(buttons, { -- action buttons
{
text = _("Choose folder"),
callback = function()
require("ui/downloadmgr"):new{
onConfirm = function(path)
logger.dbg("Download folder set to", path)
G_reader_settings:saveSetting("download_dir", path)
self.download_dialog:setTitle(createTitle(path, filename))
end,
}:chooseDir(self.getCurrentDownloadDir())
end,
},
{
text = _("Change filename"),
callback = function()
local input_dialog
input_dialog = InputDialog:new{
title = _("Enter filename"),
input = filename,
input_hint = filename_orig,
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(input_dialog)
end,
},
{
text = _("Set filename"),
is_enter_default = true,
callback = function()
filename = input_dialog:getInputValue()
if filename == "" then
filename = filename_orig
end
UIManager:close(input_dialog)
self.download_dialog:setTitle(createTitle(self.getCurrentDownloadDir(), filename))
end,
},
}
},
}
UIManager:show(input_dialog)
input_dialog:onShowKeyboard()
end,
},
})
table.insert(buttons, {
{
text = _("Cancel"),
callback = function()
UIManager:close(self.download_dialog)
end,
},
{
text = _("Book information"),
enabled = type(item.content) == "string",
callback = function()
local TextViewer = require("ui/widget/textviewer")
UIManager:show(TextViewer:new{
title = item.text,
title_multilines = true,
text = util.htmlToPlainTextIfHtml(item.content),
text_face = Font:getFace("x_smallinfofont", G_reader_settings:readSetting("items_font_size")),
})
end,
},
})
self.download_dialog = ButtonDialogTitle:new{
title = createTitle(self.getCurrentDownloadDir(), filename),
buttons = buttons,
}
UIManager:show(self.download_dialog)
end
function OPDSBrowser:browse(browse_url, username, password)
logger.dbg("Browse OPDS url", browse_url)
table.insert(self.paths, {
url = browse_url,
username = username,
password = password,
title = self.catalog_title,
})
if not self:updateCatalog(browse_url, username, password) then
table.remove(self.paths)
end
end
function OPDSBrowser:browseSearchable(browse_url, username, password)
self.search_server_dialog = InputDialog:new{
title = _("Search OPDS catalog"),
input = "",
-- @translators: This is an input hint for something to search for in an OPDS catalog, namely a famous author everyone knows. It probably doesn't need to be localized, but this is just here in case another name or book title would be more appropriate outside of a European context.
input_hint = _("Alexandre Dumas"),
input_type = "string",
description = _("%s in url will be replaced by your input"),
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(self.search_server_dialog)
end,
},
{
text = _("Search"),
is_enter_default = true,
callback = function()
UIManager:close(self.search_server_dialog)
local search = self.search_server_dialog:getInputText():gsub(" ", "+")
local searched_url = browse_url:gsub("%%s", search)
self:browse(searched_url, username, password)
end,
},
}
},
}
UIManager:show(self.search_server_dialog)
self.search_server_dialog:onShowKeyboard()
end
-- This function is fired when a list item is selected. The function
-- determines what action to performed based on the item's values.
-- Possible actions include: adding a catalog, acquiring a publication,
-- and navigating to another catalog.
function OPDSBrowser:onMenuSelect(item)
logger.dbg("Menu select item", item)
self.catalog_title = self.catalog_title or _("OPDS Catalog")
-- add catalog
if item.callback then
item.callback()
-- acquisition
elseif item.acquisitions and #item.acquisitions > 0 then
logger.dbg("Downloads available:", item)
self:showDownloads(item)
-- navigation
else
self.catalog_title = item.text or self.catalog_title
local connect_callback
if item.searchable then
connect_callback = function()
self:browseSearchable(item.url, item.username, item.password)
end
else
connect_callback = function()
self:browse(item.url, item.username, item.password)
end
end
NetworkMgr:runWhenConnected(connect_callback)
end
return true
end
function OPDSBrowser:editServerFromInput(item, fields)
logger.dbg("Edit OPDS catalog input:", fields)
for _, server in ipairs(self.opds_servers) do
if server.title == item.text or server.url == item.url then
server.title = fields[1]
server.url = (fields[2]:match("^%a+://") and fields[2] or "http://" .. fields[2])
server.searchable = (fields[2]:match("%%s") and true or false)
server.username = fields[3] ~= "" and fields[3] or nil
server.password = fields[4]
end
end
self:init()
end
function OPDSBrowser:editOPDSServer(item)
logger.dbg("Edit OPDS Server:", item)
self.edit_server_dialog = MultiInputDialog:new{
title = _("Edit OPDS catalog"),
fields = {
{
text = item.text or "",
hint = _("Catalog name"),
},
{
text = item.url or "",
hint = _("Catalog URL"),
},
{
text = item.username or "",
hint = _("Username (optional)"),
},
{
text = item.password or "",
hint = _("Password (optional)"),
text_type = "password",
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
self.edit_server_dialog:onClose()
UIManager:close(self.edit_server_dialog)
end
},
{
text = _("Apply"),
callback = function()
self.edit_server_dialog:onClose()
UIManager:close(self.edit_server_dialog)
self:editServerFromInput(item, self.edit_server_dialog:getFields())
end
},
},
},
}
UIManager:show(self.edit_server_dialog)
self.edit_server_dialog:onShowKeyboard()
end
function OPDSBrowser:deleteOPDSServer(item)
logger.info("Delete OPDS server:", item)
for i = #self.opds_servers, 1, -1 do
local server = self.opds_servers[i]
if server.title == item.text and server.url == item.url then
table.remove(self.opds_servers, i)
end
end
self:init()
end
function OPDSBrowser:onMenuHold(item)
if item.deletable or item.editable then
self.opds_server_dialog = ButtonDialog:new{
buttons = {
{
{
text = _("Edit"),
enabled = item.editable,
callback = function()
UIManager:close(self.opds_server_dialog)
if item.text ~= self.calibre_name then
self:editOPDSServer(item)
else
self:editCalibreServer(item)
end
end
},
{
text = _("Delete"),
enabled = item.deletable,
callback = function()
UIManager:close(self.opds_server_dialog)
self:deleteOPDSServer(item)
end
},
},
}
}
UIManager:show(self.opds_server_dialog)
return true
end
end
function OPDSBrowser:onReturn()
if #self.paths > 0 then
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, path.username, path.password)
else
-- return to root path, we simply reinit opdsbrowser
self:init()
end
end
return true
end
function OPDSBrowser:onHoldReturn()
if #self.paths > 1 then
local path = self.paths[1]
if path then
for i = #self.paths, 2, -1 do
table.remove(self.paths)
end
self.catalog_title = path.title
self:updateCatalog(path.url, path.username, path.password)
end
end
return true
end
function OPDSBrowser:onNext()
-- self.page_num comes from menu.lua
local page_num = self.page_num
-- fetch more entries until we fill out one page or reach the end
while page_num == self.page_num do
local hrefs = self.item_table.hrefs
if hrefs and hrefs.next then
if not self:appendCatalog(hrefs.next, self.item_table.username, self.item_table.password) then
break -- reach end of paging
end
else
break
end
end
return true
end
return OPDSBrowser