diff --git a/plugins/opds.koplugin/opdsbrowser.lua b/plugins/opds.koplugin/opdsbrowser.lua
index d5b4c348b..ac3e5333a 100644
--- a/plugins/opds.koplugin/opdsbrowser.lua
+++ b/plugins/opds.koplugin/opdsbrowser.lua
@@ -18,6 +18,7 @@ local TextViewer = require("ui/widget/textviewer")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local http = require("socket.http")
+local ffiUtil = require("ffi/util")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local ltn12 = require("ltn12")
@@ -27,7 +28,7 @@ local url = require("socket.url")
local util = require("util")
local _ = require("gettext")
local N_ = _.ngettext
-local T = require("ffi/util").template
+local T = ffiUtil.template
-- cache catalog parsed from feed xml
local CatalogCache = Cache:new{
@@ -42,6 +43,7 @@ local OPDSBrowser = Menu:extend{
acquisition_rel = "^http://opds%-spec%.org/acquisition",
borrow_rel = "http://opds-spec.org/acquisition/borrow",
stream_rel = "http://vaemendis.net/opds-pse/stream",
+ facet_rel = "http://opds-spec.org/facet",
image_rel = {
["http://opds-spec.org/image"] = true,
["http://opds-spec.org/cover"] = true, -- ManyBooks.net, not in spec
@@ -56,6 +58,7 @@ local OPDSBrowser = Menu:extend{
root_catalog_title = nil,
root_catalog_username = nil,
root_catalog_password = nil,
+ facet_groups = nil, -- Stores OPDS facet groups
title_shrink_font_to_fit = true,
}
@@ -67,6 +70,7 @@ function OPDSBrowser:init()
self.onLeftButtonTap = function()
self:showOPDSMenu()
end
+ self.facet_groups = nil -- Initialize facet groups storage
Menu.init(self) -- call parent's init()
end
@@ -135,6 +139,74 @@ function OPDSBrowser:showOPDSMenu()
UIManager:show(dialog)
end
+-- Shows facet menu for OPDS catalogs with facets/search support
+function OPDSBrowser:showFacetMenu()
+ local buttons = {}
+ local dialog
+ local catalog_url = self.paths[#self.paths].url
+
+ -- Add sub-catalog to bookmarks option first
+ table.insert(buttons, {{
+ text = _("Add catalog"),
+ callback = function()
+ UIManager:close(dialog)
+ self:addSubCatalog(catalog_url)
+ end,
+ align = "left",
+ }})
+ table.insert(buttons, {}) -- separator
+
+ -- Add search option if available
+ if self.search_url then
+ table.insert(buttons, {{
+ text = "\u{f002} " .. _("Search"),
+ callback = function()
+ UIManager:close(dialog)
+ self:searchCatalog(self.search_url)
+ end,
+ align = "left",
+ }})
+ table.insert(buttons, {}) -- separator
+ end
+
+ -- Add facet groups
+ if self.facet_groups then
+ for group_name, facets in ffiUtil.orderedPairs(self.facet_groups) do
+ table.insert(buttons, {
+ { text = group_name, enabled = false, align = "left" }
+ })
+
+ for __, link in ipairs(facets) do
+ local facet_text = link.title
+ if link["thr:count"] then
+ facet_text = T(_("%1 (%2)"), facet_text, link["thr:count"])
+ end
+ if link["opds:activeFacet"] == "true" then
+ facet_text = "✓ " .. facet_text
+ end
+ table.insert(buttons, {{
+ text = facet_text,
+ callback = function()
+ UIManager:close(dialog)
+ self:updateCatalog(url.absolute(catalog_url, link.href))
+ end,
+ align = "left",
+ }})
+ end
+ table.insert(buttons, {}) -- separator between groups
+ end
+ end
+
+ dialog = ButtonDialog:new{
+ buttons = buttons,
+ shrink_unneeded_width = true,
+ anchor = function()
+ return self.title_bar.left_button.image.dimen
+ end,
+ }
+ UIManager:show(dialog)
+end
+
local function buildRootEntry(server)
local icons = ""
@@ -430,13 +502,18 @@ function OPDSBrowser:genItemTableFromURL(item_url)
return self:genItemTableFromCatalog(catalog, item_url)
end
+-- Generates catalog item table and processes OPDS facets/search links
function OPDSBrowser:genItemTableFromCatalog(catalog, item_url)
local item_table = {}
+ self.facet_groups = nil -- Reset facets
+ self.search_url = nil -- Reset search URL
+
if not catalog then
return item_table
end
local feed = catalog.feed or catalog
+ self.facet_groups = {} -- Initialize table to store facet groups
local function build_href(href)
return url.absolute(item_url, href)
@@ -456,24 +533,24 @@ function OPDSBrowser:genItemTableFromCatalog(catalog, item_url)
-- OpenSearch
if link.type:find(self.search_type) then
if link.href then
- table.insert(item_table, { -- the first item in each subcatalog
- text = "\u{f002} " .. _("Search"), -- append SEARCH icon
- url = build_href(self:getSearchTemplate(build_href(link.href))),
- searchable = true,
- })
+ self.search_url = build_href(self:getSearchTemplate(build_href(link.href)))
has_opensearch = true
end
end
-- Calibre search (also matches the actual template for OpenSearch!)
if link.type:find(self.search_template_type) and link.rel and link.rel:find("search") then
if link.href and not has_opensearch then
- table.insert(item_table, {
- text = "\u{f002} " .. _("Search"),
- url = build_href(link.href:gsub("{searchTerms}", "%%s")),
- searchable = true,
- })
+ self.search_url = build_href(link.href:gsub("{searchTerms}", "%%s"))
end
end
+ -- Process OPDS facets
+ if link.rel == self.facet_rel then
+ local group_name = link["opds:facetGroup"] or _("Filters")
+ if not self.facet_groups[group_name] then
+ self.facet_groups[group_name] = {}
+ end
+ table.insert(self.facet_groups[group_name], link)
+ end
end
end
end
@@ -591,13 +668,16 @@ function OPDSBrowser:genItemTableFromCatalog(catalog, item_url)
item.content = entry.content or entry.summary
table.insert(item_table, item)
end
+
+ if next(self.facet_groups) == nil then self.facet_groups = nil end -- Clear if empty
+
return item_table
end
-- Requests and shows updated list of catalog entries
function OPDSBrowser:updateCatalog(item_url, paths_updated)
local menu_table = self:genItemTableFromURL(item_url)
- if #menu_table > 0 then
+ if #menu_table > 0 or self.facet_groups or self.search_url then
if not paths_updated then
table.insert(self.paths, {
url = item_url,
@@ -605,10 +685,20 @@ function OPDSBrowser:updateCatalog(item_url, paths_updated)
})
end
self:switchItemTable(self.catalog_title, menu_table)
- self:setTitleBarLeftIcon("plus")
- self.onLeftButtonTap = function()
- self:addSubCatalog(item_url)
+
+ -- Set appropriate title bar icon based on content
+ if self.facet_groups or self.search_url then
+ self:setTitleBarLeftIcon("appbar.settings")
+ self.onLeftButtonTap = function()
+ self:showFacetMenu()
+ end
+ else
+ self:setTitleBarLeftIcon("plus")
+ self.onLeftButtonTap = function()
+ self:addSubCatalog(item_url)
+ end
end
+
if self.page_num <= 1 then
-- Request more content, but don't change the page
self:onNextPage(true)
@@ -620,11 +710,8 @@ end
function OPDSBrowser:appendCatalog(item_url)
local menu_table = self:genItemTableFromURL(item_url)
if #menu_table > 0 then
- for _, item in ipairs(menu_table) do
- -- Don't append multiple search entries
- if not item.searchable then
- table.insert(self.item_table, item)
- end
+ for __, item in ipairs(menu_table) do
+ table.insert(self.item_table, item)
end
self.item_table.hrefs = menu_table.hrefs
self:switchItemTable(self.catalog_title, self.item_table, -1)
diff --git a/spec/unit/opds_spec.lua b/spec/unit/opds_spec.lua
index cb312e77b..f2c56239c 100644
--- a/spec/unit/opds_spec.lua
+++ b/spec/unit/opds_spec.lua
@@ -186,6 +186,39 @@ One Thousand Mythological Characters Briefly Described
]]
+-- https://www.gutenberg.org/catalog/osd-books.xml
+local opensearch_sample = [[
+
+
+ Project Gutenberg
+ Gutenberg
+ Search the Project Gutenberg ebook catalog.
+ free ebooks books public domain
+ Marcello Perathoner
+ webmaster@gutenberg.org
+
+
+
+
+
+
+
+
+
+
+
+ Search Data Copyright 1971-2012, Project Gutenberg, All Rights Reserved.
+ open
+ en-us
+ UTF-8
+ UTF-8
+
+]]
+
local popular_new_sample = [[
@@ -320,38 +353,70 @@ describe("OPDS module", function()
end)
describe("OPDS browser module", function()
+ before_each(function()
+ local Cache = require("cache")
+ stub(Cache, "check", function() return nil end)
+ end)
+
+ after_each(function()
+ local Cache = require("cache")
+ if Cache.check.revert then
+ Cache.check:revert()
+ end
+ end)
+
describe("URL generation", function()
- it("should generate search item #internet", function()
- local catalog = OPDSParser:parse(navigation_sample)
- local item_table = OPDSBrowser:genItemTableFromCatalog(catalog, "https://www.gutenberg.org/ebooks.opds/?format=opds")
+ it("should generate search url and catalog items #internet", function()
+ local fetch_feed_stub = stub(OPDSBrowser, "getSearchTemplate", function(self, osd_url)
+ local search_descriptor = OPDSParser:parse(opensearch_sample)
+ 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
+ return nil
+ end)
+
+ local main_catalog = OPDSParser:parse(navigation_sample)
+ local item_table = OPDSBrowser:genItemTableFromCatalog(main_catalog, "https://www.gutenberg.org/ebooks.opds/?format=opds")
+
+ assert.truthy(OPDSBrowser.search_url)
+ assert.are.same("http://m.gutenberg.org/ebooks/search.opds/?query=%s", OPDSBrowser.search_url)
assert.truthy(item_table)
- assert.are.same(item_table[1].text, "\u{f002} " .. "Search")
+ assert.are.same(3, #item_table)
+ assert.are.same("Popular", item_table[1].title)
+ assert.are.same("Latest", item_table[2].title)
+ assert.are.same("Random", item_table[3].title)
+
+ fetch_feed_stub:revert()
end)
it("should generate URL on rel=subsection #internet", function()
local catalog = OPDSParser:parse(navigation_sample)
local item_table = OPDSBrowser:genItemTableFromCatalog(catalog, "https://www.gutenberg.org/ebooks.opds/?format=opds")
assert.truthy(item_table)
- assert.are.same(item_table[2].title, "Popular")
- assert.are.same(item_table[2].url, "https://www.gutenberg.org/ebooks/search.opds/?sort_order=downloads")
+ assert.are.same(item_table[1].title, "Popular")
+ assert.are.same(item_table[1].url, "https://www.gutenberg.org/ebooks/search.opds/?sort_order=downloads")
end)
it("should generate URL on rel=popular and rel=new #internet", function()
local catalog = OPDSParser:parse(popular_new_sample)
local item_table = OPDSBrowser:genItemTableFromCatalog(catalog, "http://www.feedbooks.com/publicdomain/catalog.atom")
assert.truthy(item_table)
- assert.are.same(item_table[2].title, "Most popular")
- assert.are.same(item_table[2].url, "https://catalog.feedbooks.com/publicdomain/browse/top.atom?lang=en")
- assert.are.same(item_table[3].title, "Recently added")
- assert.are.same(item_table[3].url, "https://catalog.feedbooks.com/publicdomain/browse/recent.atom?lang=en")
+ assert.are.same(item_table[1].title, "Most popular")
+ assert.are.same(item_table[1].url, "https://catalog.feedbooks.com/publicdomain/browse/top.atom?lang=en")
+ assert.are.same(item_table[2].title, "Recently added")
+ assert.are.same(item_table[2].url, "https://catalog.feedbooks.com/publicdomain/browse/recent.atom?lang=en")
end)
it("should use the main URL for faceted links as long as faceted links aren't properly supported #internet", function()
local catalog = OPDSParser:parse(facet_sample)
local item_table = OPDSBrowser:genItemTableFromCatalog(catalog, "http://flibusta.is/opds")
assert.truthy(item_table)
- assert.are.same(item_table[2].url, "http://flibusta.is/opds/author/75357")
+ assert.are.same(item_table[1].url, "http://flibusta.is/opds/author/75357")
end)
end)
@@ -360,7 +425,7 @@ describe("OPDS module", function()
local item_table = OPDSBrowser:genItemTableFromCatalog(catalog, "http://flibusta.is/opds")
assert.truthy(item_table)
- assert.are_not.same(item_table[2].image, "http://flibusta.is/opds/author/75357")
+ assert.are_not.same(item_table[1].image, "http://flibusta.is/opds/author/75357")
end)
end)
end)