2
0
mirror of https://github.com/koreader/koreader synced 2024-10-31 21:20:20 +00:00
koreader/frontend/ui/widget/opdsbrowser.lua
Nick Savage 85585a0973 Fixed bug where only calibre OPDS catalogs would prompt to enable Wifi.
Tested on Kindle PW3. The error message my device threw was different from what was coded in OPDSBrowser.lua in getCatalog(). Perhaps we should test other devices and see if there are other error messages thrown as well.
2017-01-23 11:15:35 -05:00

722 lines
23 KiB
Lua

local MultiInputDialog = require("ui/widget/multiinputdialog")
local ButtonDialog = require("ui/widget/buttondialog")
local InfoMessage = require("ui/widget/infomessage")
local LoginDialog = require("ui/widget/logindialog")
local OPDSParser = require("ui/opdsparser")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local CacheItem = require("cacheitem")
local Menu = require("ui/widget/menu")
local Screen = require("device").screen
local url = require('socket.url')
local util = require("ffi/util")
local Cache = require("cache")
local logger = require("logger")
local _ = require("gettext")
local socket = require('socket')
local http = require('socket.http')
local https = require('ssl.https')
local ltn12 = require('ltn12')
local mime = require('mime')
local CatalogCacheItem = CacheItem:new{
size = 1024, -- fixed size for catalog item
}
-- cache catalog parsed from feed xml
local CatalogCache = Cache:new{
max_memsize = 20*1024, -- keep only 20 cache items
current_memsize = 0,
cache = {},
cache_order = {},
}
local OPDSBrowser = Menu:extend{
opds_servers = {},
calibre_name = _("Local calibre catalog"),
catalog_type = "application/atom%+xml",
search_type = "application/opensearchdescription%+xml",
acquisition_rel = "^http://opds%-spec%.org/acquisition",
image_rel = "http://opds-spec.org/image",
thumbnail_rel = "http://opds-spec.org/image/thumbnail",
formats = {
["application/epub+zip"] = "EPUB",
["application/fb2+zip"] = "FB2",
["application/pdf"] = "PDF",
["text/plain"] = "TXT",
["application/x-mobipocket-ebook"] = "MOBI",
["application/x-mobi8-ebook"] = "AZW3",
},
width = Screen:getWidth(),
height = Screen:getHeight(),
no_title = false,
parent = nil,
}
function OPDSBrowser:init()
local servers = G_reader_settings:readSetting("opds_servers")
if not servers then -- If there are no saved servers, add some defaults
servers = {
{
title = "Project Gutenberg",
url = "http://m.gutenberg.org/ebooks.opds/?format=opds",
},
{
title = "Feedbooks",
url = "http://www.feedbooks.com/publicdomain/catalog.atom",
},
{
title = "ManyBooks",
url = "http://manybooks.net/opds/index.php",
},
{
title = "Internet Archive",
url = "http://bookserver.archive.org/catalog/",
},
}
G_reader_settings:saveSetting("opds_servers", servers)
end
self.item_table = self:genItemTableFromRoot()
Menu.init(self) -- call parent's init()
end
function OPDSBrowser:addServerFromInput(fields)
logger.dbg("input catalog", fields)
local servers = G_reader_settings:readSetting("opds_servers") or {}
table.insert(servers, {
title = fields[1],
url = (fields[2]:match("^%a+://") and fields[2] or "http://" .. fields[2]),
})
G_reader_settings:saveSetting("opds_servers", servers)
self:init()
end
function OPDSBrowser:editCalibreFromInput(fields)
logger.dbg("input calibre server", fields)
local calibre = G_reader_settings:readSetting("calibre_opds") or {}
if fields[1] then
calibre.host = fields[1]
end
if tonumber(fields[2]) then
calibre.port = fields[2]
end
G_reader_settings:saveSetting("calibre_opds", calibre)
self:init()
end
function OPDSBrowser:addNewCatalog()
self.add_server_dialog = MultiInputDialog:new{
title = _("Add OPDS catalog"),
fields = {
{
text = "",
hint = _("Catalog name"),
},
{
text = "",
hint = _("Catalog URL"),
},
},
buttons = {
{
{
text = _("Cancel"),
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(MultiInputDialog:getFields())
end
},
},
},
width = Screen:getWidth() * 0.95,
height = Screen:getHeight() * 0.2,
}
self.add_server_dialog:onShowKeyboard()
UIManager:show(self.add_server_dialog)
end
function OPDSBrowser:editCalibreServer()
local calibre = G_reader_settings:readSetting("calibre_opds") or {}
self.add_server_dialog = MultiInputDialog:new{
title = _("Edit local calibre host and port"),
fields = {
{
-- TODO: get IP address of current device
text = calibre.host or "192.168.1.1",
hint = _("calibre host"),
},
{
text = calibre.port and tostring(calibre.port) or "8080",
hint = _("calibre port"),
},
},
buttons = {
{
{
text = _("Cancel"),
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(MultiInputDialog:getFields())
end
},
},
},
width = Screen:getWidth() * 0.95,
height = Screen:getHeight() * 0.2,
}
self.add_server_dialog:onShowKeyboard()
UIManager:show(self.add_server_dialog)
end
function OPDSBrowser:genItemTableFromRoot()
local item_table = {}
local added_servers = G_reader_settings:readSetting("opds_servers") or {}
for _, server in ipairs(added_servers) do
table.insert(item_table, {
text = server.title,
content = server.subtitle,
url = server.url,
deletable = true,
editable = true,
})
end
local calibre_opds = G_reader_settings:readSetting("calibre_opds") or {}
if not calibre_opds.host or not calibre_opds.port then
table.insert(item_table, {
text = self.calibre_name,
callback = function()
self:editCalibreServer()
end,
deletable = false,
})
else
table.insert(item_table, {
text = self.calibre_name,
url = string.format("http://%s:%d/opds",
calibre_opds.host, calibre_opds.port),
editable = true,
deletable = false,
})
end
table.insert(item_table, {
text = _("Add new OPDS catalog"),
callback = function()
self:addNewCatalog()
end,
})
return item_table
end
function OPDSBrowser:getBasicAuthentication(host)
local authentications = G_reader_settings:readSetting("www-auth") or {}
return authentications[host]
end
function OPDSBrowser:setBasicAuthentication(host, username, password)
local authentications = G_reader_settings:readSetting("www-auth") or {}
authentications[host] = {
username = username,
password = password,
}
G_reader_settings:saveSetting("www-auth", authentications)
end
function OPDSBrowser:getAuthorizationHeader(host)
local auth = self:getBasicAuthentication(host)
if auth then
local authorization = auth.username .. ':' .. auth.password
return {
Authorization = "Basic " .. mime.b64(authorization),
}
end
end
function OPDSBrowser:fetchFeed(feed_url)
local request, sink = {}, {}
local parsed = url.parse(feed_url)
request['url'] = feed_url
request['method'] = 'GET'
request['sink'] = ltn12.sink.table(sink)
request['headers'] = self:getAuthorizationHeader(parsed.host)
logger.dbg("request", request)
http.TIMEOUT, https.TIMEOUT = 10, 10
local httpRequest = parsed.scheme == 'http' and http.request or https.request
local code, headers, status = socket.skip(1, httpRequest(request))
-- raise error message when network is unavailable
if headers == nil then
error(code)
end
if code == 401 and status and status:find("Unauthorized") then
self._coroutine = coroutine.running() or self._coroutine
self:fetchWithLogin(parsed.host, function()
return self:fetchFeed(feed_url)
end)
if coroutine.running() then
local result = coroutine.yield()
return result
end
else
local xml = table.concat(sink)
if xml ~= "" then
return xml
end
end
end
function OPDSBrowser:fetchWithLogin(host, callback)
self.login_dialog = LoginDialog:new{
title = _("Login to OPDS server"),
username = "",
buttons = {
{
{
text = _("Cancel"),
enabled = true,
callback = function()
self:closeDialog()
end,
},
{
text = _("Login"),
enabled = true,
callback = function()
local username, password = self:getCredential()
self:setBasicAuthentication(host, username, password)
self:closeDialog()
UIManager:scheduleIn(0.5, function()
local res = callback()
if res then
coroutine.resume(self._coroutine, res)
end
end)
end,
},
},
},
width = Screen:getWidth() * 0.8,
height = Screen:getHeight() * 0.4,
}
self.login_dialog:onShowKeyboard()
UIManager:show(self.login_dialog)
end
function OPDSBrowser:closeDialog()
self.login_dialog:onClose()
UIManager:close(self.login_dialog)
end
function OPDSBrowser:getCredential()
return self.login_dialog:getCredential()
end
function OPDSBrowser:parseFeed(feed_url)
local feed
local hash = "opds|catalog|" .. feed_url
local cache = CatalogCache:check(hash)
if cache then
feed = cache.feed
else
logger.dbg("cache", hash)
feed = self:fetchFeed(feed_url)
if feed then
CatalogCache:insert(hash, CatalogCacheItem:new{ feed = feed })
end
end
if feed then
return OPDSParser:parse(feed)
end
end
function OPDSBrowser:getCatalog(feed_url)
local ok, catalog = pcall(self.parseFeed, self, feed_url)
-- prompt users to turn on Wifi if network is unreachable
if not ok and catalog and (catalog:find("Network is unreachable") or catalog:find("host or service not provided")) then
NetworkMgr:promptWifiOn()
return
elseif not ok and catalog then
logger.warn("cannot get catalog info from", feed_url, catalog)
UIManager:show(InfoMessage:new{
text = util.template(
_("Cannot get catalog info from %1"),
(feed_url or "")
),
})
return
end
if ok and catalog then
logger.dbg("catalog", catalog)
return catalog
end
end
function OPDSBrowser:genItemTableFromURL(item_url)
local catalog = self:getCatalog(item_url)
return self:genItemTableFromCatalog(catalog, item_url)
end
function OPDSBrowser:genItemTableFromCatalog(catalog, item_url)
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) or
link.type:find(self.search_type) then
if link.rel and link.href then
hrefs[link.rel] = build_href(link.href)
end
end
end
end
end
item_table.hrefs = hrefs
if not feed.entry then
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:find(self.catalog_type)
and (not link.rel
or link.rel == "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
if link.rel then
if link.rel:match(self.acquisition_rel) then
table.insert(item.acquisitions, {
type = link.type,
href = build_href(link.href),
})
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
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.warn("Cannot handle title", entry.title)
end
local author = "Unknown Author"
if type(entry.author) == "table" and entry.author.name then
author = entry.author.name
end
item.text = title
item.title = title
item.author = author
item.id = entry.id
item.content = entry.content
item.updated = entry.updated
table.insert(item_table, item)
end
return item_table
end
function OPDSBrowser:updateCatalog(item_table_url)
local menu_table = self:genItemTableFromURL(item_table_url)
if #menu_table > 0 then
self:swithItemTable(nil, menu_table)
if self.page_num <= 1 then
self:onNext()
end
return true
end
end
function OPDSBrowser:appendCatalog(item_table_url)
local new_table = self:genItemTableFromURL(item_table_url)
for _, item in ipairs(new_table) do
table.insert(self.item_table, item)
end
self.item_table.hrefs = new_table.hrefs
self:swithItemTable(nil, self.item_table, -1)
return true
end
function OPDSBrowser:downloadFile(item, format, remote_url)
-- download to user selected directory or last opened dir
local lastdir = G_reader_settings:readSetting("lastdir")
local download_dir = G_reader_settings:readSetting("download_dir") or lastdir
local utl = require("frontend/util")
local file_system = utl.getFilesystemType(download_dir)
if file_system == "vfat" or file_system == "fuse.fsp" then
item.author = utl.replaceInvalidChars(item.author)
item.title = utl.replaceInvalidChars(item.title)
else
item.author = utl.replaceSlashChar(item.author)
item.title = utl.replaceSlashChar(item.title)
end
local local_path = download_dir .. "/" .. item.author .. ' - ' .. item.title .. "." .. string.lower(format)
logger.dbg("downloading file", local_path, "from", remote_url)
local parsed = url.parse(remote_url)
http.TIMEOUT, https.TIMEOUT = 10, 10
local httpRequest = parsed.scheme == 'http' and http.request or https.request
local _, c, _ = httpRequest{
url = remote_url,
headers = self:getAuthorizationHeader(parsed.host),
sink = ltn12.sink.file(io.open(local_path, "w")),
}
if c == 200 then
logger.dbg("file downloaded to", local_path)
if self.file_downloaded_callback then
self.file_downloaded_callback(local_path)
end
else
UIManager:show(InfoMessage:new{
text = _("Could not save file to:\n") .. local_path,
timeout = 3,
})
end
end
function OPDSBrowser:showDownloads(item)
local acquisitions = item.acquisitions
local downloadsperline = 2
local lines = math.ceil(#acquisitions/downloadsperline)
local buttons = {}
for i = 1, lines do
local line = {}
for j = 1, downloadsperline do
local button = {}
local index = (i-1)*downloadsperline + j
local acquisition = acquisitions[index]
if acquisition then
local format = self.formats[acquisition.type]
if format then
button.text = format
button.callback = function()
UIManager:scheduleIn(1, function()
self:downloadFile(item, format, acquisition.href)
end)
UIManager:close(self.download_dialog)
UIManager:show(InfoMessage:new{
text = _("Downloading may take several minutes…"),
timeout = 1,
})
end
table.insert(line, button)
end
end
end
table.insert(buttons, line)
end
-- set download directory button
table.insert(buttons, {
{
text = _("Set download directory"),
callback = function()
require("ui/downloadmgr"):new{
title = _("Choose download directory"),
onConfirm = function(path)
logger.dbg("set download directory to", path)
G_reader_settings:saveSetting("download_dir", path)
end,
}:chooseDir()
end,
}
})
self.download_dialog = ButtonDialog:new{
buttons = buttons
}
UIManager:show(self.download_dialog)
end
function OPDSBrowser:onMenuSelect(item)
-- 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
table.insert(self.paths, {
url = item.url,
})
if not self:updateCatalog(item.url) then
table.remove(self.paths)
end
end
return true
end
function OPDSBrowser:editServerFromInput(item, fields)
logger.dbg("input catalog", fields)
local servers = {}
for _, server in ipairs(G_reader_settings:readSetting("opds_servers") or {}) 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])
end
table.insert(servers, server)
end
G_reader_settings:saveSetting("opds_servers", servers)
self:init()
end
function OPDSBrowser:editOPDSServer(item)
logger.dbg("edit", 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"),
},
},
buttons = {
{
{
text = _("Cancel"),
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, MultiInputDialog:getFields())
end
},
},
},
width = Screen:getWidth() * 0.95,
height = Screen:getHeight() * 0.2,
}
self.edit_server_dialog:onShowKeyboard()
UIManager:show(self.edit_server_dialog)
end
function OPDSBrowser:deleteOPDSServer(item)
logger.dbg("delete", item)
local servers = {}
for _, server in ipairs(G_reader_settings:readSetting("opds_servers") or {}) do
if server.title ~= item.text or server.url ~= item.url then
table.insert(servers, server)
end
end
G_reader_settings:saveSetting("opds_servers", servers)
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()
logger.dbg("return to last page catalog")
if #self.paths > 0 then
table.remove(self.paths)
local path = self.paths[#self.paths]
if path then
-- return to last path
self:updateCatalog(path.url)
else
-- return to root path, we simply reinit opdsbrowser
self:init()
end
end
return true
end
function OPDSBrowser:onNext()
logger.dbg("fetch next page catalog")
local hrefs = self.item_table.hrefs
local page_num = self.page_num
while page_num == self.page_num and hrefs and hrefs.next do
self:appendCatalog(hrefs.next)
end
return true
end
return OPDSBrowser