add provider module (#12641)

* implements a Provider singleton, to be used by thirdparty plugins
* exporter: support for thirdparty providers
* splits plugin loading into two steps: discovery and load
  1. get a list of all candidate plugins to load for the different paths
  2. sort providers before on the rest of them and try to load them
This commit is contained in:
Martín Fernández
2024-12-18 19:40:22 +01:00
committed by GitHub
parent 9df814593d
commit e503cc4b9c
5 changed files with 256 additions and 57 deletions

View File

@@ -1,5 +1,6 @@
local lfs = require("libs/libkoreader-lfs") local lfs = require("libs/libkoreader-lfs")
local logger = require("logger") local logger = require("logger")
local util = require("util")
local _ = require("gettext") local _ = require("gettext")
local DEFAULT_PLUGIN_PATH = "plugins" local DEFAULT_PLUGIN_PATH = "plugins"
@@ -22,6 +23,22 @@ local DEPRECATION_MESSAGES = {
feature = _("The following features are unmaintained and will be removed soon:"), feature = _("The following features are unmaintained and will be removed soon:"),
} }
local function isProvider(name)
return name:sub(1, 8) == "provider"
end
local function sortProvidersFirst(v1, v2)
if isProvider(v1.name) and isProvider(v2.name) then
return v1.path < v2.path
elseif isProvider(v1.name) then
return true
elseif isProvider(v2.name) then
return false
else
return v1.path < v2.path
end
end
local function deprecationFmt(field) local function deprecationFmt(field)
local s local s
if type(field) == "table" then if type(field) == "table" then
@@ -76,12 +93,17 @@ local PluginLoader = {
all_plugins = nil, all_plugins = nil,
} }
function PluginLoader:loadPlugins() function PluginLoader:_discover()
if self.enabled_plugins then return self.enabled_plugins, self.disabled_plugins end local plugins_disabled = G_reader_settings:readSetting("plugins_disabled")
if type(plugins_disabled) ~= "table" then
plugins_disabled = {}
end
-- disable obsolete plugins
for element in pairs(OBSOLETE_PLUGINS) do
plugins_disabled[element] = true
end
self.enabled_plugins = {} local discovered = {}
self.disabled_plugins = {}
self.loaded_plugins = {}
local lookup_path_list = { DEFAULT_PLUGIN_PATH } local lookup_path_list = { DEFAULT_PLUGIN_PATH }
local extra_paths = G_reader_settings:readSetting("extra_plugin_paths") local extra_paths = G_reader_settings:readSetting("extra_plugin_paths")
if extra_paths then if extra_paths then
@@ -106,21 +128,8 @@ function PluginLoader:loadPlugins()
table.insert(lookup_path_list, extra_path) table.insert(lookup_path_list, extra_path)
end end
end end
-- keep reference to old value so they can be restored later
local package_path = package.path
local package_cpath = package.cpath
local plugins_disabled = G_reader_settings:readSetting("plugins_disabled")
if type(plugins_disabled) ~= "table" then
plugins_disabled = {}
end
-- disable obsolete plugins
for element in pairs(OBSOLETE_PLUGINS) do
plugins_disabled[element] = true
end
for _, lookup_path in ipairs(lookup_path_list) do for _, lookup_path in ipairs(lookup_path_list) do
logger.info("Loading plugins from directory:", lookup_path) logger.info("Looking for plugins in directory:", lookup_path)
for entry in lfs.dir(lookup_path) do for entry in lfs.dir(lookup_path) do
local plugin_root = lookup_path.."/"..entry local plugin_root = lookup_path.."/"..entry
local mode = lfs.attributes(plugin_root, "mode") local mode = lfs.attributes(plugin_root, "mode")
@@ -128,36 +137,76 @@ function PluginLoader:loadPlugins()
if mode == "directory" and entry:find(".+%.koplugin$") then if mode == "directory" and entry:find(".+%.koplugin$") then
local mainfile = plugin_root.."/main.lua" local mainfile = plugin_root.."/main.lua"
local metafile = plugin_root.."/_meta.lua" local metafile = plugin_root.."/_meta.lua"
local disabled = false
if plugins_disabled and plugins_disabled[entry:sub(1, -10)] then if plugins_disabled and plugins_disabled[entry:sub(1, -10)] then
mainfile = metafile mainfile = metafile
disabled = true
end end
package.path = string.format("%s/?.lua;%s", plugin_root, package_path) local __, name = util.splitFilePathName(plugin_root)
package.cpath = string.format("%s/lib/?.so;%s", plugin_root, package_cpath)
local ok, plugin_module = pcall(dofile, mainfile) table.insert(discovered, {
if not ok or not plugin_module then ["main"] = mainfile,
logger.warn("Error when loading", mainfile, plugin_module) ["meta"] = metafile,
elseif type(plugin_module.disabled) ~= "boolean" or not plugin_module.disabled then ["path"] = plugin_root,
plugin_module.path = plugin_root ["disabled"] = disabled,
plugin_module.name = plugin_module.name or plugin_root:match("/(.-)%.koplugin") ["name"] = name,
if (plugins_disabled and plugins_disabled[entry:sub(1, -10)]) then })
table.insert(self.disabled_plugins, plugin_module)
else
local ok_meta, plugin_metamodule = pcall(dofile, metafile)
if ok_meta and plugin_metamodule then
for k,v in pairs(plugin_metamodule) do plugin_module[k] = v end
end
sandboxPluginEventHandlers(plugin_module)
table.insert(self.enabled_plugins, plugin_module)
end
else
logger.dbg("Plugin", mainfile, "has been disabled.")
end
package.path = package_path
package.cpath = package_cpath
end end
end end
end end
return discovered
end
function PluginLoader:_load(t)
-- keep reference to old value so they can be restored later
local package_path = package.path
local package_cpath = package.cpath
local mainfile, metafile, plugin_root, disabled
for _, v in ipairs(t) do
mainfile = v.main
metafile = v.meta
plugin_root = v.path
disabled = v.disabled
package.path = string.format("%s/?.lua;%s", plugin_root, package_path)
package.cpath = string.format("%s/lib/?.so;%s", plugin_root, package_cpath)
local ok, plugin_module = pcall(dofile, mainfile)
if not ok or not plugin_module then
logger.warn("Error when loading", mainfile, plugin_module)
elseif type(plugin_module.disabled) ~= "boolean" or not plugin_module.disabled then
plugin_module.path = plugin_root
plugin_module.name = plugin_module.name or plugin_root:match("/(.-)%.koplugin")
if disabled then
table.insert(self.disabled_plugins, plugin_module)
else
local ok_meta, plugin_metamodule = pcall(dofile, metafile)
if ok_meta and plugin_metamodule then
for k, module in pairs(plugin_metamodule) do
plugin_module[k] = module
end
end
sandboxPluginEventHandlers(plugin_module)
table.insert(self.enabled_plugins, plugin_module)
logger.dbg("Plugin loaded", plugin_module.name)
end
end
end
package.path = package_path
package.cpath = package_cpath
end
function PluginLoader:loadPlugins()
if self.enabled_plugins then return self.enabled_plugins, self.disabled_plugins end
self.enabled_plugins = {}
self.disabled_plugins = {}
self.loaded_plugins = {}
local t = self:_discover()
table.sort(t, sortProvidersFirst)
self:_load(t)
-- set package path for all loaded plugins -- set package path for all loaded plugins
for _, plugin in ipairs(self.enabled_plugins) do for _, plugin in ipairs(self.enabled_plugins) do
package.path = string.format("%s;%s/?.lua", package.path, plugin.path) package.path = string.format("%s;%s/?.lua", package.path, plugin.path)

92
frontend/provider.lua Normal file
View File

@@ -0,0 +1,92 @@
--[[
Provider is a singleton that holds thirdparty implementations for features.
To be used on plugins, prefixed with "provider-", that implement specific feature APIs.
]]--
local util = require("util")
local function aTable(t)
if type(t) ~= "table" then
return {}
end
return t
end
local Provider = {
features = {
["exporter"] = {},
},
}
function Provider:_isValidFeature(s)
return self.features[s] ~= nil
end
--[[--
Registers an implementation of a feature.
@param feature string that identifies the feature
@param name string that identifies the provider
@param impl table with implementation details
@treturn bool registered
]]
function Provider:register(feature, name, impl)
if type(name) ~= "string" or type(feature) ~= "string" or type(impl) ~= "table" then
return false
end
if self:_isValidFeature(feature) then
self.features[feature][name] = impl
return true
end
return false
end
--[[--
Unregisters an implementation of a feature.
@param feature string feature identifier
@param name string provider identifier
@treturn bool unregistered
]]
function Provider:unregister(feature, name)
if type(name) ~= "string" or type(feature) ~= "string" then
return false
end
if self:_isValidFeature(feature) then
self.features[feature][name] = nil
return true
end
return false
end
--[[--
Counts providers for a given feature
@param feature string feature identifier
@treturn int number
]]
function Provider:size(feature)
local count = 0
if self:_isValidFeature(feature) then
count = util.tableSize(aTable(self.features[feature]))
end
return count
end
--[[--
Get providers for a given feature
@param feature string feature identifier
@treturn table provider/implementation k/v pairs.
]]
function Provider:getProvidersTable(feature)
if self:_isValidFeature(feature) then
return aTable(self.features[feature])
end
return aTable()
end
return Provider

View File

@@ -31,6 +31,7 @@ local Dispatcher = require("dispatcher")
local InfoMessage = require("ui/widget/infomessage") local InfoMessage = require("ui/widget/infomessage")
local MyClipping = require("clip") local MyClipping = require("clip")
local NetworkMgr = require("ui/network/manager") local NetworkMgr = require("ui/network/manager")
local Provider = require("provider")
local ReaderHighlight = require("apps/reader/modules/readerhighlight") local ReaderHighlight = require("apps/reader/modules/readerhighlight")
local UIManager = require("ui/uimanager") local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer") local WidgetContainer = require("ui/widget/container/widgetcontainer")
@@ -99,27 +100,43 @@ local function updateMyClippings(clippings, new_clippings)
return clippings return clippings
end end
local targets = {
html = require("target/html"),
joplin = require("target/joplin"),
json = require("target/json"),
markdown = require("target/markdown"),
my_clippings = require("target/my_clippings"),
nextcloud = require("target/nextcloud"),
readwise = require("target/readwise"),
text = require("target/text"),
xmnote = require("target/xmnote"),
}
local function genExportersTable(path)
local t = {}
for k, v in pairs(targets) do
t[k] = v
end
if Provider:size("exporter") > 0 then
local tbl = Provider:getProvidersTable("exporter")
for k, v in pairs(tbl) do
t[k] = v
end
end
for _, v in pairs(t) do
v.path = path
end
return t
end
local Exporter = WidgetContainer:extend{ local Exporter = WidgetContainer:extend{
name = "exporter", name = "exporter",
targets = {
html = require("target/html"),
joplin = require("target/joplin"),
json = require("target/json"),
markdown = require("target/markdown"),
my_clippings = require("target/my_clippings"),
nextcloud = require("target/nextcloud"),
readwise = require("target/readwise"),
text = require("target/text"),
xmnote = require("target/xmnote"),
},
} }
function Exporter:init() function Exporter:init()
migrateSettings() migrateSettings()
self.parser = MyClipping:new{} self.parser = MyClipping:new{}
for _, v in pairs(self.targets) do self.targets = genExportersTable(self.path)
v.path = self.path
end
self.ui.menu:registerToMainMenu(self) self.ui.menu:registerToMainMenu(self)
self:onDispatcherRegisterActions() self:onDispatcherRegisterActions()
end end

View File

@@ -1,6 +1,6 @@
-- Set search path for `require()`. -- Set search path for `require()`.
package.path = package.path =
"common/?.lua;frontend/?.lua;" .. "common/?.lua;frontend/?.lua;plugins/exporter.koplugin/?.lua;" ..
package.path package.path
package.cpath = package.cpath =
"common/?.so;common/?.dll;/usr/lib/lua/?.so;" .. "common/?.so;common/?.dll;/usr/lib/lua/?.so;" ..

View File

@@ -0,0 +1,41 @@
describe("Provider module", function()
local Provider
local t
setup(function()
require("commonrequire")
Provider = require("provider")
end)
it("should fail to register an improper provider", function()
assert.is_false(Provider:register())
end)
it("should fail to unregister an improper provider", function()
assert.is_false(Provider:unregister())
end)
it("should register a proper provider with empty implementation", function()
assert.is_true(Provider:register("exporter", "test", {}))
end)
it("should override an implementation for the same name of the same kind", function()
assert.is_true(Provider:register("exporter", "test", { test = function() end }))
assert.is_true(type(Provider.features["exporter"]["test"].test) == "function")
end)
it("should unregister a provider", function()
assert.is_true(Provider:unregister("exporter", "test"))
end)
it("should count providers for a specific feature", function()
assert.is_true(Provider:register("exporter", "test1", {}))
assert.is_true(Provider:register("exporter", "test2", {}))
assert.is_true(Provider:register("exporter", "test3", {}))
assert.is_true(Provider:size("exporter") == 3)
end)
it("should dump a table of providers for a specific feature", function()
assert.are.same(Provider.features["exporter"],
Provider:getProvidersTable("exporter"))
end)
it("should dump an empty table for an invalid feature", function()
t = Provider:getProvidersTable("invalid")
assert.is_true(type(t) == "table")
end)
end)