From 3d8aedf9c34e6927b6d3b11445d5f0e669b347d7 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Wed, 1 May 2024 18:37:38 +0000 Subject: [PATCH] feat(plugins): add options to manifest for building forms and storing plugin settings --- app/Config/Autoload.php | 2 +- modules/Plugins/BasePlugin.php | 65 ++++++++++++++++--- modules/Plugins/Config/Routes.php | 8 +++ .../Plugins/Controllers/PluginsController.php | 38 +++++++++++ modules/Plugins/Helpers/plugins_helper.php | 42 ++++++++++++ modules/Plugins/Plugins.php | 33 ++++++++-- themes/cp_admin/plugins/_plugin.php | 4 ++ themes/cp_admin/plugins/settings.php | 15 +++++ 8 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 themes/cp_admin/plugins/settings.php diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 69589a22..c75ea20c 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -114,7 +114,7 @@ class Autoload extends AutoloadConfig public function __construct() { // load plugins namespaces - $pluginsPaths = glob(ROOTPATH . '/plugins/*', GLOB_ONLYDIR); + $pluginsPaths = glob(ROOTPATH . '/plugins/*', GLOB_ONLYDIR | GLOB_NOSORT); if (! $pluginsPaths) { $pluginsPaths = []; diff --git a/modules/Plugins/BasePlugin.php b/modules/Plugins/BasePlugin.php index 973dc31e..db808aa4 100644 --- a/modules/Plugins/BasePlugin.php +++ b/modules/Plugins/BasePlugin.php @@ -20,7 +20,9 @@ use RuntimeException; * @property string $license * @property string $compatible * @property string[] $keywords + * @property string[] $hooks * @property string $iconSrc + * @property array{settings:array{key:string,name:string,description:string}[],podcast:array{key:string,name:string,description:string}[],episode:array{key:string,name:string,description:string}[]} $options */ abstract class BasePlugin implements PluginInterface { @@ -76,6 +78,11 @@ abstract class BasePlugin implements PluginInterface return $this->active; } + final public function isHookDeclared(string $name): bool + { + return in_array($name, $this->hooks, true); + } + final public function getKey(): string { return $this->key; @@ -138,6 +145,31 @@ abstract class BasePlugin implements PluginInterface throw new RuntimeException('manifest.json is not a valid JSON', 1); } + $validation = service('validation'); + + if (array_key_exists('options', $manifest)) { + $optionRules = [ + 'key' => 'required|alpha_numeric', + 'name' => 'required|max_length[32]', + 'description' => 'permit_empty|max_length[128]', + ]; + $defaultOption = [ + 'key' => '', + 'name' => '', + 'description' => '', + ]; + $validation->setRules($optionRules); + foreach ($manifest['options'] as $key => $options) { + foreach ($options as $key2 => $option) { + $manifest['options'][$key][$key2] = array_merge($defaultOption, $option); + + if (! $validation->run($manifest['options'][$key][$key2])) { + dd($this->key, $manifest['options'][$key][$key2], $validation->getErrors()); + } + } + } + } + $rules = [ 'name' => 'required|max_length[32]', 'version' => 'required|regex_match[/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/]', @@ -145,22 +177,39 @@ abstract class BasePlugin implements PluginInterface 'description' => 'max_length[128]', 'releaseDate' => 'valid_date[Y-m-d]', 'license' => 'in_list[MIT]', - 'author.name' => 'max_length[32]', - 'author.email' => 'valid_email', - 'author.url' => 'valid_url_strict', + 'author.name' => 'permit_empty|max_length[32]', + 'author.email' => 'permit_empty|valid_email', + 'author.url' => 'permit_empty|valid_url_strict', 'website' => 'valid_url_strict', - 'keywords.*' => 'in_list[seo,podcasting20,analytics]', + 'keywords.*' => 'permit_empty|in_list[seo,podcasting20,analytics]', + 'hooks.*' => 'permit_empty|in_list[' . implode(',', Plugins::HOOKS) . ']', + 'options' => 'permit_empty', ]; - $validation = service('validation'); - $validation->setRules($rules); if (! $validation->run($manifest)) { - dd($validation->getErrors()); + dd($this->key, $manifest, $validation->getErrors()); } - return $validation->getValidated(); + $defaultAttributes = [ + 'description' => '', + 'releaseDate' => '', + 'license' => '', + 'author' => [], + 'website' => '', + 'hooks' => [], + 'keywords' => [], + 'options' => [ + 'settings' => [], + 'podcast' => [], + 'episode' => [], + ], + ]; + + $validated = $validation->getValidated(); + + return array_merge_recursive_distinct($defaultAttributes, $validated); } private function loadIcon(string $path): string diff --git a/modules/Plugins/Config/Routes.php b/modules/Plugins/Config/Routes.php index ff80bddd..c2e4caa8 100644 --- a/modules/Plugins/Config/Routes.php +++ b/modules/Plugins/Config/Routes.php @@ -17,6 +17,14 @@ $routes->group( 'as' => 'plugins-installed', 'filter' => 'permission:plugins.manage', ]); + $routes->get('(:segment)', 'PluginsController::settings/$1', [ + 'as' => 'plugins-settings', + 'filter' => 'permission:plugins.manage', + ]); + $routes->post('(:segment)', 'PluginsController::settingsAction/$1', [ + 'as' => 'plugins-settings-action', + 'filter' => 'permission:plugins.manage', + ]); $routes->post('activate/(:segment)', 'PluginsController::activate/$1', [ 'as' => 'plugins-activate', 'filter' => 'permission:plugins.manage', diff --git a/modules/Plugins/Controllers/PluginsController.php b/modules/Plugins/Controllers/PluginsController.php index dc3989c9..e0ed0477 100644 --- a/modules/Plugins/Controllers/PluginsController.php +++ b/modules/Plugins/Controllers/PluginsController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Modules\Plugins\Controllers; +use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; use Modules\Admin\Controllers\BaseController; use Modules\Plugins\Plugins; @@ -30,6 +31,43 @@ class PluginsController extends BaseController ]); } + public function settings(string $pluginKey): string + { + /** @var Plugins $plugins */ + $plugins = service('plugins'); + + $plugin = $plugins->getPlugin($pluginKey); + + if ($plugin === null) { + throw PageNotFoundException::forPageNotFound(); + } + + helper('form'); + return view('plugins/settings', [ + 'plugin' => $plugin, + ]); + } + + public function settingsAction(string $pluginKey): RedirectResponse + { + /** @var Plugins $plugins */ + $plugins = service('plugins'); + + $plugin = $plugins->getPlugin($pluginKey); + + if ($plugin === null) { + throw PageNotFoundException::forPageNotFound(); + } + + foreach ($plugin->options['settings'] as $option) { + $optionKey = $option['key']; + $optionValue = $this->request->getPost($optionKey); + $plugins->setOption($pluginKey, $optionKey, $optionValue); + } + + return redirect()->back(); + } + public function activate(string $pluginKey): RedirectResponse { service('plugins')->activate($pluginKey); diff --git a/modules/Plugins/Helpers/plugins_helper.php b/modules/Plugins/Helpers/plugins_helper.php index 8b0e205e..b71536bc 100644 --- a/modules/Plugins/Helpers/plugins_helper.php +++ b/modules/Plugins/Helpers/plugins_helper.php @@ -22,3 +22,45 @@ if (! function_exists('set_plugin_option')) { ->set($key, $value, $context); } } + +if (! function_exists('array_merge_recursive_distinct')) { + /** + * array_merge_recursive does indeed merge arrays, but it converts values with duplicate + * keys to arrays rather than overwriting the value in the first array with the duplicate + * value in the second array, as array_merge does. I.e., with array_merge_recursive, + * this happens (documented behavior): + * + * array_merge_recursive(array('key' => 'org value'), array('key' => 'new value')); + * => array('key' => array('org value', 'new value')); + * + * array_merge_recursive_distinct does not change the datatypes of the values in the arrays. + * Matching keys' values in the second array overwrite those in the first array, as is the + * case with array_merge, i.e.: + * + * array_merge_recursive_distinct(array('key' => 'org value'), array('key' => 'new value')); + * => array('key' => array('new value')); + * + * Parameters are passed by reference, though only for performance reasons. They're not + * altered by this function. + * + * from https://www.php.net/manual/en/function.array-merge-recursive.php#92195 + * + * @param array $array1 + * @param array $array2 + * @return array + */ + function array_merge_recursive_distinct(array &$array1, array &$array2): array + { + $merged = $array1; + + foreach ($array2 as $key => &$value) { + if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { + $merged[$key] = array_merge_recursive_distinct($merged[$key], $value); + } else { + $merged[$key] = $value; + } + } + + return $merged; + } +} diff --git a/modules/Plugins/Plugins.php b/modules/Plugins/Plugins.php index 0995cc2a..531c20dc 100644 --- a/modules/Plugins/Plugins.php +++ b/modules/Plugins/Plugins.php @@ -15,12 +15,12 @@ use App\Libraries\SimpleRSSElement; */ class Plugins { - protected const API_VERSION = '1.0'; + public const API_VERSION = '1.0'; /** * @var list */ - protected const HOOKS = ['channelTag', 'itemTag', 'siteHead']; + public const HOOKS = ['channelTag', 'itemTag', 'siteHead']; /** * @var array @@ -57,6 +57,17 @@ class Plugins return array_slice(static::$plugins, (($page - 1) * $perPage), $perPage); } + public function getPlugin(string $key): ?BasePlugin + { + foreach (static::$plugins as $plugin) { + if ($plugin->getKey() === $key) { + return $plugin; + } + } + + return null; + } + /** * @param value-of $name * @param array $arguments @@ -65,9 +76,15 @@ class Plugins { foreach (static::$plugins as $plugin) { // only run hook on active plugins - if ($plugin->isActive()) { - $plugin->{$name}(...$arguments); + if (! $plugin->isActive()) { + continue; } + + if (! $plugin->isHookDeclared($name)) { + continue; + } + + $plugin->{$name}(...$arguments); } } @@ -81,6 +98,11 @@ class Plugins set_plugin_option($pluginKey, 'active', false); } + public function setOption(string $pluginKey, string $name, string $value): void + { + set_plugin_option($pluginKey, $name, $value); + } + public function getInstalledCount(): int { return static::$installedCount; @@ -89,7 +111,8 @@ class Plugins protected function registerPlugins(): void { // search for plugins in plugins folder - $pluginsFiles = glob(ROOTPATH . '/plugins/**/Plugin.php', GLOB_NOSORT); + // TODO: only get directories? Should be organized as author/repo? + $pluginsFiles = glob(ROOTPATH . '/plugins/**/Plugin.php'); if (! $pluginsFiles) { return; diff --git a/themes/cp_admin/plugins/_plugin.php b/themes/cp_admin/plugins/_plugin.php index fe0f41a7..5b613ef8 100644 --- a/themes/cp_admin/plugins/_plugin.php +++ b/themes/cp_admin/plugins/_plugin.php @@ -8,6 +8,10 @@ 'text-gray-500', ]) . lang('Plugins.website') ?> + options['settings'] !== []): ?> + + Settings + isActive()): ?>
diff --git a/themes/cp_admin/plugins/settings.php b/themes/cp_admin/plugins/settings.php new file mode 100644 index 00000000..5148d378 --- /dev/null +++ b/themes/cp_admin/plugins/settings.php @@ -0,0 +1,15 @@ +extend('_layout') ?> + +section('content') ?> + + +options['settings'] as $option): ?> + + + + +endSection() ?>