mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
feat(plugins): add options to manifest for building forms and storing plugin settings
This commit is contained in:
parent
e80a33bf2a
commit
3d8aedf9c3
@ -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 = [];
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
|
@ -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<mixed> $array1
|
||||
* @param array<mixed> $array2
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -15,12 +15,12 @@ use App\Libraries\SimpleRSSElement;
|
||||
*/
|
||||
class Plugins
|
||||
{
|
||||
protected const API_VERSION = '1.0';
|
||||
public const API_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const HOOKS = ['channelTag', 'itemTag', 'siteHead'];
|
||||
public const HOOKS = ['channelTag', 'itemTag', 'siteHead'];
|
||||
|
||||
/**
|
||||
* @var array<BasePlugin>
|
||||
@ -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<static::HOOKS> $name
|
||||
* @param array<mixed> $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;
|
||||
|
@ -8,6 +8,10 @@
|
||||
<a href="<?= $plugin->website ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [
|
||||
'class' => 'text-gray-500',
|
||||
]) . lang('Plugins.website') ?></a>
|
||||
<?php if ($plugin->options['settings'] !== []): ?>
|
||||
<?php // @icon('equalizer-fill')?>
|
||||
<IconButton uri="<?= route_to('plugins-settings', $plugin->getKey()) ?>" glyph="equalizer-fill">Settings</IconButton>
|
||||
<?php endif; ?>
|
||||
<?php if($plugin->isActive()): ?>
|
||||
<form class="flex justify-end" method="POST" action="<?= route_to('plugins-deactivate', $plugin->getKey()) ?>">
|
||||
<?= csrf_field() ?>
|
||||
|
15
themes/cp_admin/plugins/settings.php
Normal file
15
themes/cp_admin/plugins/settings.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?= $this->extend('_layout') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
<form method="POST" action="<?= route_to('plugins-settings-action', $plugin->getKey()) ?>" class="flex flex-col max-w-sm gap-4" >
|
||||
<?= csrf_field() ?>
|
||||
<?php foreach ($plugin->options['settings'] as $option): ?>
|
||||
<Forms.Field
|
||||
name="<?= $option['key'] ?>"
|
||||
label="<?= $option['name'] ?>"
|
||||
hint="<?= $option['description'] ?>"
|
||||
/>
|
||||
<?php endforeach; ?>
|
||||
<Button class="self-end mt-4" variant="primary" type="submit"><?= lang('Plugins.form.save') ?></Button>
|
||||
</form>
|
||||
<?= $this->endSection() ?>
|
Loading…
x
Reference in New Issue
Block a user