From 95088196ceb2c4100961e0c9fc957f71f00dc262 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Mon, 6 May 2024 16:00:47 +0000 Subject: [PATCH] feat(plugins): load and validate plugin manifest.json --- .devcontainer/devcontainer.json | 2 +- app/Config/Validation.php | 2 + app/Helpers/rss_helper.php | 2 +- app/Validation/OtherRules.php | 24 ++ modules/Admin/Language/en/Validation.php | 2 + modules/Plugins/BasePlugin.php | 243 ------------------ modules/Plugins/Commands/UninstallPlugin.php | 2 +- modules/Plugins/Config/Services.php | 2 +- .../Plugins/Controllers/PluginController.php | 23 +- modules/Plugins/Core/BasePlugin.php | 211 +++++++++++++++ .../Plugins/{ => Core}/PluginInterface.php | 2 +- modules/Plugins/{ => Core}/Plugins.php | 6 +- modules/Plugins/Helpers/plugins_helper.php | 44 +--- modules/Plugins/Manifest/Author.php | 56 ++++ modules/Plugins/Manifest/Manifest.php | 81 ++++++ modules/Plugins/Manifest/ManifestObject.php | 79 ++++++ modules/Plugins/Manifest/Settings.php | 43 ++++ modules/Plugins/Manifest/SettingsField.php | 37 +++ .../schema.json} | 0 themes/cp_admin/plugins/_plugin.php | 8 +- themes/cp_admin/plugins/_settings.php | 14 +- 21 files changed, 565 insertions(+), 318 deletions(-) create mode 100644 app/Validation/OtherRules.php delete mode 100644 modules/Plugins/BasePlugin.php create mode 100644 modules/Plugins/Core/BasePlugin.php rename modules/Plugins/{ => Core}/PluginInterface.php (91%) rename modules/Plugins/{ => Core}/Plugins.php (97%) create mode 100644 modules/Plugins/Manifest/Author.php create mode 100644 modules/Plugins/Manifest/Manifest.php create mode 100644 modules/Plugins/Manifest/ManifestObject.php create mode 100644 modules/Plugins/Manifest/Settings.php create mode 100644 modules/Plugins/Manifest/SettingsField.php rename modules/Plugins/{manifest.schema.json => Manifest/schema.json} (100%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c3dfb905..d6c31aff 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -34,7 +34,7 @@ "json.schemas": [ { "fileMatch": ["plugins/**/manifest.json"], - "url": "/workspaces/castopod/modules/Plugins/manifest.schema.json" + "url": "/workspaces/castopod/modules/Plugins/Manifest/schema.json" } ] }, diff --git a/app/Config/Validation.php b/app/Config/Validation.php index ed2e0324..65644059 100644 --- a/app/Config/Validation.php +++ b/app/Config/Validation.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Config; use App\Validation\FileRules as AppFileRules; +use App\Validation\OtherRules; use CodeIgniter\Config\BaseConfig; use CodeIgniter\Validation\StrictRules\CreditCardRules; use CodeIgniter\Validation\StrictRules\FileRules; @@ -24,6 +25,7 @@ class Validation extends BaseConfig FileRules::class, CreditCardRules::class, AppFileRules::class, + OtherRules::class, ]; /** diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index bf7918ba..2abafbe4 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -16,7 +16,7 @@ use CodeIgniter\I18n\Time; use Config\Mimes; use Modules\Media\Entities\Chapters; use Modules\Media\Entities\Transcript; -use Modules\Plugins\Plugins; +use Modules\Plugins\Core\Plugins; use Modules\PremiumPodcasts\Entities\Subscription; if (! function_exists('get_rss_feed')) { diff --git a/app/Validation/OtherRules.php b/app/Validation/OtherRules.php new file mode 100644 index 00000000..390388d7 --- /dev/null +++ b/app/Validation/OtherRules.php @@ -0,0 +1,24 @@ + '{field} is either not an image or not of the right ratio.', 'is_json' => '{field} contains invalid JSON.', + 'is_boolean' => 'The {field} field must be a boolean (true or false).', + 'is_list' => 'The {field} field must be an array.', ]; diff --git a/modules/Plugins/BasePlugin.php b/modules/Plugins/BasePlugin.php deleted file mode 100644 index cf3fd0fa..00000000 --- a/modules/Plugins/BasePlugin.php +++ /dev/null @@ -1,243 +0,0 @@ -key = sprintf('%s/%s', $vendor, $package); - - $manifest = $this->loadManifest($directory . '/manifest.json'); - - foreach ($manifest as $key => $value) { - $this->{$key} = $value; - } - - // check that plugin is active - $this->active = get_plugin_option($this->key, 'active') ?? false; - - $this->iconSrc = $this->loadIcon($directory . '/icon.svg'); - } - - /** - * @param list|string $value - */ - public function __set(string $name, array|string $value): void - { - $this->{$name} = $value; - } - - public function init(): void - { - // add to admin navigation - - // TODO: setup navigation and views? - } - - public function channelTag(Podcast $podcast, SimpleRSSElement $channel): void - { - } - - public function itemTag(Episode $episode, SimpleRSSElement $item): void - { - } - - public function siteHead(): void - { - } - - final public function isActive(): bool - { - 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; - } - - final public function getVendor(): string - { - return $this->vendor; - } - - final public function getPackage(): string - { - return $this->package; - } - - final public function getName(): string - { - $key = sprintf('Plugin.%s.name', $this->key); - /** @var string $name */ - $name = lang($key); - - if ($name === $key) { - return $this->name; - } - - return $name; - } - - final public function getDescription(): string - { - $key = sprintf('Plugin.%s.description', $this->key); - - /** @var string $description */ - $description = lang($key); - - if ($description === $key) { - return $this->description; - } - - return $description; - } - - final protected function getOption(string $option): mixed - { - return get_plugin_option($this->key, $option); - } - - final protected function setOption(string $option, mixed $value = null): void - { - set_plugin_option($this->key, $option, $value); - } - - /** - * @return array> - */ - private function loadManifest(string $path): array - { - // TODO: cache manifest data - - $manifestContents = file_get_contents($path); - - if (! $manifestContents) { - throw new RuntimeException('manifest file not found!'); - } - - /** @var array|null $manifest */ - $manifest = json_decode($manifestContents, true); - - if ($manifest === null) { - throw new RuntimeException('manifest.json is not a valid JSON', 1); - } - - $validation = service('validation'); - - if (array_key_exists('settings', $manifest)) { - $fieldRules = [ - 'key' => 'required|alpha_numeric', - 'label' => 'required|max_length[32]', - 'hint' => 'permit_empty|max_length[128]', - 'helper' => 'permit_empty|max_length[128]', - ]; - $defaultField = [ - 'key' => '', - 'label' => '', - 'hint' => '', - 'helper' => '', - 'optional' => false, - ]; - $validation->setRules($fieldRules); - foreach ($manifest['settings'] as $key => $settings) { - foreach ($settings as $key2 => $fields) { - $manifest['settings'][$key][$key2] = array_merge($defaultField, $fields); - - if (! $validation->run($manifest['settings'][$key][$key2])) { - dd($this->key, $manifest['settings'][$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-]+)*))?$/]', - 'description' => 'max_length[128]', - 'license' => 'in_list[MIT]', - 'author.name' => 'permit_empty|max_length[32]', - 'author.email' => 'permit_empty|valid_email', - 'author.url' => 'permit_empty|valid_url_strict', - 'homepage' => 'valid_url_strict', - 'keywords.*' => 'permit_empty|in_list[seo,podcasting20,analytics]', - 'hooks.*' => 'permit_empty|in_list[' . implode(',', Plugins::HOOKS) . ']', - 'settings' => 'permit_empty', - ]; - - $validation->setRules($rules); - - if (! $validation->run($manifest)) { - dd($this->key, $manifest, $validation->getErrors()); - } - - $defaultAttributes = [ - 'description' => '', - 'license' => '', - 'author' => [], - 'homepage' => '', - 'hooks' => [], - 'keywords' => [], - 'settings' => [ - 'general' => [], - 'podcast' => [], - 'episode' => [], - ], - ]; - - $validated = $validation->getValidated(); - - return array_merge_recursive_distinct($defaultAttributes, $validated); - } - - private function loadIcon(string $path): string - { - // TODO: cache icon - $svgIcon = @file_get_contents($path); - - if (! $svgIcon) { - return "data:image/svg+xml;utf8,%3Csvg xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg' viewBox='0 0 64 64'%3E%3Cpath fill='%2300564A' d='M0 0h64v64H0z'%2F%3E%3Cpath fill='%23E7F9E4' d='M25.3 18.7a5 5 0 1 1 9.7 1.6h7c1 0 1.7.8 1.7 1.7v7a5 5 0 1 1 0 9.4v7c0 .9-.8 1.6-1.7 1.6H18.7c-1 0-1.7-.7-1.7-1.7V22c0-1 .7-1.7 1.7-1.7h7a5 5 0 0 1-.4-1.6Z'%2F%3E%3C%2Fsvg%3E"; - } - - $encodedIcon = rawurlencode(str_replace(["\r", "\n"], ' ', $svgIcon)); - return 'data:image/svg+xml;utf8,' . str_replace( - ['%20', '%22', '%27', '%3D'], - [' ', "'", "'", '='], - $encodedIcon - ); - } -} diff --git a/modules/Plugins/Commands/UninstallPlugin.php b/modules/Plugins/Commands/UninstallPlugin.php index 4d52772f..8a2aacb3 100644 --- a/modules/Plugins/Commands/UninstallPlugin.php +++ b/modules/Plugins/Commands/UninstallPlugin.php @@ -6,7 +6,7 @@ namespace Modules\Plugins\Commands; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; -use Modules\Plugins\Plugins; +use Modules\Plugins\Core\Plugins; class UninstallPlugin extends BaseCommand { diff --git a/modules/Plugins/Config/Services.php b/modules/Plugins/Config/Services.php index 96113ee7..84dcd1c8 100644 --- a/modules/Plugins/Config/Services.php +++ b/modules/Plugins/Config/Services.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Modules\Plugins\Config; use CodeIgniter\Config\BaseService; -use Modules\Plugins\Plugins; +use Modules\Plugins\Core\Plugins; class Services extends BaseService { diff --git a/modules/Plugins/Controllers/PluginController.php b/modules/Plugins/Controllers/PluginController.php index e187c376..5507552d 100644 --- a/modules/Plugins/Controllers/PluginController.php +++ b/modules/Plugins/Controllers/PluginController.php @@ -11,7 +11,7 @@ use App\Models\PodcastModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; use Modules\Admin\Controllers\BaseController; -use Modules\Plugins\Plugins; +use Modules\Plugins\Core\Plugins; class PluginController extends BaseController { @@ -76,10 +76,9 @@ class PluginController extends BaseController throw PageNotFoundException::forPageNotFound(); } - foreach ($plugin->settings['general'] as $option) { - $optionKey = $option['key']; - $optionValue = $this->request->getPost($optionKey); - $plugins->setOption($plugin, $optionKey, $optionValue); + foreach ($plugin->getSettingsFields('general') as $field) { + $optionValue = $this->request->getPost($field->key); + $plugins->setOption($plugin, $field->key, $optionValue); } return redirect()->back() @@ -126,10 +125,9 @@ class PluginController extends BaseController throw PageNotFoundException::forPageNotFound(); } - foreach ($plugin->settings['podcast'] as $setting) { - $settingKey = $setting['key']; - $settingValue = $this->request->getPost($settingKey); - $plugins->setOption($plugin, $settingKey, $settingValue, ['podcast', (int) $podcastId]); + foreach ($plugin->getSettingsFields('podcast') as $field) { + $settingValue = $this->request->getPost($field->key); + $plugins->setOption($plugin, $field->key, $settingValue, ['podcast', (int) $podcastId]); } return redirect()->back() @@ -182,10 +180,9 @@ class PluginController extends BaseController throw PageNotFoundException::forPageNotFound(); } - foreach ($plugin->settings['episode'] as $setting) { - $settingKey = $setting['key']; - $settingValue = $this->request->getPost($settingKey); - $plugins->setOption($plugin, $settingKey, $settingValue, ['episode', (int) $episodeId]); + foreach ($plugin->getSettingsFields('episode') as $field) { + $settingValue = $this->request->getPost($field->key); + $plugins->setOption($plugin, $field->key, $settingValue, ['episode', (int) $episodeId]); } return redirect()->back() diff --git a/modules/Plugins/Core/BasePlugin.php b/modules/Plugins/Core/BasePlugin.php new file mode 100644 index 00000000..ff31b96f --- /dev/null +++ b/modules/Plugins/Core/BasePlugin.php @@ -0,0 +1,211 @@ +key = sprintf('%s/%s', $vendor, $package); + + // TODO: cache manifest data + $manifestPath = $directory . '/manifest.json'; + $manifestContents = file_get_contents($manifestPath); + + if (! $manifestContents) { + throw new RuntimeException(sprintf('Plugin manifest "%s" is missing!', $manifestPath)); + } + + /** @var array|null $manifestData */ + $manifestData = json_decode($manifestContents, true); + + if ($manifestData === null) { + throw new RuntimeException(sprintf('Plugin manifest "%s" is not a valid JSON', $manifestPath), 1); + } + + $this->manifest = new Manifest($manifestData); + + // check that plugin is active + $this->active = get_plugin_option($this->key, 'active') ?? false; + + $this->iconSrc = $this->loadIcon($directory . '/icon.svg'); + } + + /** + * @param list|string $value + */ + public function __set(string $name, array|string $value): void + { + $this->{$name} = $value; + } + + public function init(): void + { + // add to admin navigation + + // TODO: setup navigation and views? + } + + public function channelTag(Podcast $podcast, SimpleRSSElement $channel): void + { + } + + public function itemTag(Episode $episode, SimpleRSSElement $item): void + { + } + + public function siteHead(): void + { + } + + final public function isActive(): bool + { + return $this->active; + } + + final public function isHookDeclared(string $name): bool + { + return in_array($name, $this->manifest->hooks, true); + } + + final public function getSettings(): ?Settings + { + return $this->manifest->settings; + } + + final public function getVersion(): string + { + return $this->manifest->version; + } + + final public function getHomepage(): ?URI + { + return $this->manifest->homepage; + } + + final public function getIconSrc(): string + { + return $this->iconSrc; + } + + final public function doesManifestHaveErrors(): bool + { + return $this->getManifestErrors() !== []; + } + + /** + * @return array + */ + final public function getManifestErrors(): array + { + return $this->manifest::$errors; + } + + /** + * @return SettingsField[] + */ + final public function getSettingsFields(string $type): array + { + $settings = $this->getSettings(); + if (! $settings instanceof Settings) { + return []; + } + + return $settings->{$type}; + } + + final public function getKey(): string + { + return $this->key; + } + + final public function getVendor(): string + { + return $this->vendor; + } + + final public function getPackage(): string + { + return $this->package; + } + + final public function getName(): string + { + $key = sprintf('Plugin.%s.name', $this->key); + /** @var string $name */ + $name = lang($key); + + if ($name === $key) { + return $this->manifest->name; + } + + return $name; + } + + final public function getDescription(): ?string + { + $key = sprintf('Plugin.%s.description', $this->key); + + /** @var string $description */ + $description = lang($key); + + if ($description === $key) { + return $this->manifest->description; + } + + return $description; + } + + final protected function getOption(string $option): mixed + { + return get_plugin_option($this->key, $option); + } + + final protected function setOption(string $option, mixed $value = null): void + { + set_plugin_option($this->key, $option, $value); + } + + private function loadIcon(string $path): string + { + // TODO: cache icon + $svgIcon = @file_get_contents($path); + + if (! $svgIcon) { + return "data:image/svg+xml;utf8,%3Csvg xmlns='http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg' viewBox='0 0 64 64'%3E%3Cpath fill='%2300564A' d='M0 0h64v64H0z'%2F%3E%3Cpath fill='%23E7F9E4' d='M25.3 18.7a5 5 0 1 1 9.7 1.6h7c1 0 1.7.8 1.7 1.7v7a5 5 0 1 1 0 9.4v7c0 .9-.8 1.6-1.7 1.6H18.7c-1 0-1.7-.7-1.7-1.7V22c0-1 .7-1.7 1.7-1.7h7a5 5 0 0 1-.4-1.6Z'%2F%3E%3C%2Fsvg%3E"; + } + + $encodedIcon = rawurlencode(str_replace(["\r", "\n"], ' ', $svgIcon)); + return 'data:image/svg+xml;utf8,' . str_replace( + ['%20', '%22', '%27', '%3D'], + [' ', "'", "'", '='], + $encodedIcon + ); + } +} diff --git a/modules/Plugins/PluginInterface.php b/modules/Plugins/Core/PluginInterface.php similarity index 91% rename from modules/Plugins/PluginInterface.php rename to modules/Plugins/Core/PluginInterface.php index 229ff290..9f9595ff 100644 --- a/modules/Plugins/PluginInterface.php +++ b/modules/Plugins/Core/PluginInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Plugins; +namespace Modules\Plugins\Core; use App\Entities\Episode; use App\Entities\Podcast; diff --git a/modules/Plugins/Plugins.php b/modules/Plugins/Core/Plugins.php similarity index 97% rename from modules/Plugins/Plugins.php rename to modules/Plugins/Core/Plugins.php index 7a2af425..ec3968aa 100644 --- a/modules/Plugins/Plugins.php +++ b/modules/Plugins/Core/Plugins.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Plugins; +namespace Modules\Plugins\Core; use App\Entities\Episode; use App\Entities\Podcast; @@ -74,7 +74,7 @@ class Plugins continue; } - if ($plugin->settings['podcast'] === []) { + if ($plugin->getSettingsFields('podcast') === []) { continue; } @@ -95,7 +95,7 @@ class Plugins continue; } - if ($plugin->settings['episode'] === []) { + if ($plugin->getSettingsFields('episode') === []) { continue; } diff --git a/modules/Plugins/Helpers/plugins_helper.php b/modules/Plugins/Helpers/plugins_helper.php index f475cad5..6af0beba 100644 --- a/modules/Plugins/Helpers/plugins_helper.php +++ b/modules/Plugins/Helpers/plugins_helper.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Modules\Plugins\Plugins; +use Modules\Plugins\Core\Plugins; if (! function_exists('plugins')) { function plugins(): Plugins @@ -49,45 +49,3 @@ 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/Manifest/Author.php b/modules/Plugins/Manifest/Author.php new file mode 100644 index 00000000..97e8d9ac --- /dev/null +++ b/modules/Plugins/Manifest/Author.php @@ -0,0 +1,56 @@ + 'required', + 'email' => 'permit_empty|valid_email', + 'url' => 'permit_empty|valid_url_strict', + ]; + + protected const AUTHOR_STRING_PATTERN = '/^(?[^<>()]*)\s*(<(?.*)>)?\s*(\((?.*)\))?$/'; + + /** + * @var array + */ + protected const CASTS = [ + 'url' => URI::class, + ]; + + protected string $name; + + protected ?string $email = null; + + protected ?URI $url = null; + + public function __construct(array|string $data) + { + if (is_string($data)) { + $result = preg_match(self::AUTHOR_STRING_PATTERN, $data, $matches); + + if (! $result) { + throw new Exception('Author string is not valid.'); + } + + $data = [ + 'name' => $matches['name'], + 'email' => $matches['email'], + 'url' => $matches['url'], + ]; + } + + parent::__construct($data); + } +} diff --git a/modules/Plugins/Manifest/Manifest.php b/modules/Plugins/Manifest/Manifest.php new file mode 100644 index 00000000..6e701da5 --- /dev/null +++ b/modules/Plugins/Manifest/Manifest.php @@ -0,0 +1,81 @@ + $keywords + * @property list $hooks + * @property ?Settings $settings + */ +class Manifest extends ManifestObject +{ + /** + * @var array + */ + protected const VALIDATION_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-]+)*))?$/]', + 'description' => 'permit_empty|max_length[128]', + 'author' => 'permit_empty', + 'authors' => 'permit_empty|is_list', + 'homepage' => 'permit_empty|valid_url_strict', + 'license' => 'permit_empty|string', + 'private' => 'permit_empty|is_boolean', + 'keywords.*' => 'permit_empty', + 'hooks.*' => 'permit_empty|in_list[channelTag,itemTag,siteHead]', + 'settings' => 'permit_empty', + ]; + + /** + * @var array + */ + protected const CASTS = [ + 'author' => Author::class, + 'authors' => [Author::class], + 'homepage' => URI::class, + 'settings' => Settings::class, + ]; + + protected string $name; + + protected string $version; + + protected ?string $description = null; + + protected ?Author $author = null; + + /** + * @var Author[] + */ + protected array $authors = []; + + protected ?URI $homepage = null; + + protected ?string $license = null; + + protected bool $private = false; + + /** + * @var list + */ + protected array $keywords = []; + + /** + * @var list + */ + protected array $hooks = []; + + protected ?Settings $settings = null; +} diff --git a/modules/Plugins/Manifest/ManifestObject.php b/modules/Plugins/Manifest/ManifestObject.php new file mode 100644 index 00000000..16cf701c --- /dev/null +++ b/modules/Plugins/Manifest/ManifestObject.php @@ -0,0 +1,79 @@ + + */ + protected const CASTS = []; + + /** + * @var array + */ + public static array $errors = []; + + /** + * @param mixed[] $data + */ + public function __construct( + private readonly array $data + ) { + $this->load(); + } + + public function __get(string $name): mixed + { + if (isset($this->{$name})) { + return $this->{$name}; + } + + throw new Exception('Undefined object property ' . static::class . '::' . $name); + } + + public function load(): void + { + /** @var Validation $validation */ + $validation = service('validation'); + + $validation->setRules($this::VALIDATION_RULES); + + if (! $validation->run($this->data)) { + static::$errors = [...static::$errors, ...$validation->getErrors()]; + } + + foreach ($validation->getValidated() as $key => $value) { + if (array_key_exists($key, $this::CASTS)) { + $cast = $this::CASTS[$key]; + + if (is_array($cast)) { + if (is_array($value)) { + foreach ($value as $valueKey => $valueElement) { + $value[$valueKey] = new $cast[0]($valueElement); + } + } + } else { + $value = new $cast($value); + } + } + + $this->{$key} = $value; + } + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/modules/Plugins/Manifest/Settings.php b/modules/Plugins/Manifest/Settings.php new file mode 100644 index 00000000..d627144b --- /dev/null +++ b/modules/Plugins/Manifest/Settings.php @@ -0,0 +1,43 @@ + 'permit_empty|is_list', + 'podcast' => 'permit_empty|is_list', + 'episode' => 'permit_empty|is_list', + ]; + + /** + * @var array + */ + protected const CASTS = [ + 'general' => [SettingsField::class], + 'podcast' => [SettingsField::class], + 'episode' => [SettingsField::class], + ]; + + /** + * @var SettingsField[] + */ + protected array $general = []; + + /** + * @var SettingsField[] + */ + protected array $podcast = []; + + /** + * @var SettingsField[] + */ + protected array $episode = []; +} diff --git a/modules/Plugins/Manifest/SettingsField.php b/modules/Plugins/Manifest/SettingsField.php new file mode 100644 index 00000000..d011b579 --- /dev/null +++ b/modules/Plugins/Manifest/SettingsField.php @@ -0,0 +1,37 @@ + 'permit_empty|in_list[text,email,url,markdown,number,switch]', + 'key' => 'required|alpha_dash', + 'label' => 'required|string', + 'hint' => 'permit_empty|string', + 'helper' => 'permit_empty|string', + 'optional' => 'permit_empty|is_boolean', + ]; + + protected string $type = 'text'; + + protected string $key; + + protected string $label; + + protected ?string $hint = ''; + + protected ?string $helper = ''; + + protected bool $optional = false; +} diff --git a/modules/Plugins/manifest.schema.json b/modules/Plugins/Manifest/schema.json similarity index 100% rename from modules/Plugins/manifest.schema.json rename to modules/Plugins/Manifest/schema.json diff --git a/themes/cp_admin/plugins/_plugin.php b/themes/cp_admin/plugins/_plugin.php index 02441de5..8b57eeb3 100644 --- a/themes/cp_admin/plugins/_plugin.php +++ b/themes/cp_admin/plugins/_plugin.php @@ -1,18 +1,18 @@
- settings['general'] !== []): ?> + getSettings() !== []): ?> $plugin->getName(), ]) ?> - +
-

getName() ?>version ?>

+

getName() ?>getVersion() ?>

getVendor() ?>/getPackage() ?>

getDescription() ?>