mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
feat(plugins): load and validate plugin manifest.json
This commit is contained in:
parent
7bdde9a4f5
commit
95088196ce
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -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')) {
|
||||
|
24
app/Validation/OtherRules.php
Normal file
24
app/Validation/OtherRules.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validation;
|
||||
|
||||
class OtherRules
|
||||
{
|
||||
/**
|
||||
* Is a boolean (true or false)
|
||||
*/
|
||||
public function is_boolean(mixed $str = null): bool
|
||||
{
|
||||
return is_bool($str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is it an array?
|
||||
*/
|
||||
public function is_list(mixed $str = null): bool
|
||||
{
|
||||
return is_array($str);
|
||||
}
|
||||
}
|
@ -14,4 +14,6 @@ return [
|
||||
'is_image_ratio' =>
|
||||
'{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.',
|
||||
];
|
||||
|
@ -1,243 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins;
|
||||
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
||||
use App\Libraries\SimpleRSSElement;
|
||||
use CodeIgniter\I18n\Time;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @property string $key
|
||||
* @property string $name
|
||||
* @property string $description
|
||||
* @property string $version
|
||||
* @property string $website
|
||||
* @property Time $releaseDate
|
||||
* @property string $author
|
||||
* @property string $license
|
||||
* @property string $compatible
|
||||
* @property string[] $keywords
|
||||
* @property string[] $hooks
|
||||
* @property string $iconSrc
|
||||
* @property array{general:array{key:string,name:string,description:string}[],podcast:array{key:string,name:string,description:string}[],episode:array{key:string,name:string,description:string}[]} $settings
|
||||
*/
|
||||
abstract class BasePlugin implements PluginInterface
|
||||
{
|
||||
protected bool $active;
|
||||
|
||||
public function __construct(
|
||||
protected string $vendor,
|
||||
protected string $package,
|
||||
protected string $directory
|
||||
) {
|
||||
$this->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>|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<string, string|list<string>>
|
||||
*/
|
||||
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<mixed>|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
|
||||
);
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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()
|
||||
|
211
modules/Plugins/Core/BasePlugin.php
Normal file
211
modules/Plugins/Core/BasePlugin.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Core;
|
||||
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
||||
use App\Libraries\SimpleRSSElement;
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use Modules\Plugins\Manifest\Manifest;
|
||||
use Modules\Plugins\Manifest\Settings;
|
||||
use Modules\Plugins\Manifest\SettingsField;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @property string $key
|
||||
* @property string $iconSrc
|
||||
*/
|
||||
abstract class BasePlugin implements PluginInterface
|
||||
{
|
||||
protected string $key;
|
||||
|
||||
protected string $iconSrc;
|
||||
|
||||
protected bool $active;
|
||||
|
||||
protected Manifest $manifest;
|
||||
|
||||
public function __construct(
|
||||
protected string $vendor,
|
||||
protected string $package,
|
||||
protected string $directory
|
||||
) {
|
||||
$this->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<mixed>|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>|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<string,string>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins;
|
||||
namespace Modules\Plugins\Core;
|
||||
|
||||
use App\Entities\Episode;
|
||||
use App\Entities\Podcast;
|
@ -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;
|
||||
}
|
||||
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
56
modules/Plugins/Manifest/Author.php
Normal file
56
modules/Plugins/Manifest/Author.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Manifest;
|
||||
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property ?string $email
|
||||
* @property ?URI $url
|
||||
*/
|
||||
class Author extends ManifestObject
|
||||
{
|
||||
protected const VALIDATION_RULES = [
|
||||
'name' => 'required',
|
||||
'email' => 'permit_empty|valid_email',
|
||||
'url' => 'permit_empty|valid_url_strict',
|
||||
];
|
||||
|
||||
protected const AUTHOR_STRING_PATTERN = '/^(?<name>[^<>()]*)\s*(<(?<email>.*)>)?\s*(\((?<url>.*)\))?$/';
|
||||
|
||||
/**
|
||||
* @var array<string,array{string}|string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
81
modules/Plugins/Manifest/Manifest.php
Normal file
81
modules/Plugins/Manifest/Manifest.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Manifest;
|
||||
|
||||
use CodeIgniter\HTTP\URI;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string $version
|
||||
* @property ?string $description
|
||||
* @property ?Author $author
|
||||
* @property Author[] $authors
|
||||
* @property ?URI $homepage
|
||||
* @property ?string $license
|
||||
* @property bool $private
|
||||
* @property list<string> $keywords
|
||||
* @property list<string> $hooks
|
||||
* @property ?Settings $settings
|
||||
*/
|
||||
class Manifest extends ManifestObject
|
||||
{
|
||||
/**
|
||||
* @var array<string,string>
|
||||
*/
|
||||
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<string,array{string}|string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
protected array $keywords = [];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected array $hooks = [];
|
||||
|
||||
protected ?Settings $settings = null;
|
||||
}
|
79
modules/Plugins/Manifest/ManifestObject.php
Normal file
79
modules/Plugins/Manifest/ManifestObject.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Manifest;
|
||||
|
||||
use CodeIgniter\Validation\Validation;
|
||||
use Exception;
|
||||
|
||||
abstract class ManifestObject
|
||||
{
|
||||
protected const VALIDATION_RULES = [];
|
||||
|
||||
/**
|
||||
* @var array<string,string|array{string}>
|
||||
*/
|
||||
protected const CASTS = [];
|
||||
|
||||
/**
|
||||
* @var array<string,string>
|
||||
*/
|
||||
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<string,string>
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
}
|
43
modules/Plugins/Manifest/Settings.php
Normal file
43
modules/Plugins/Manifest/Settings.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Manifest;
|
||||
|
||||
/**
|
||||
* @property SettingsField[] $general
|
||||
* @property SettingsField[] $podcast
|
||||
* @property SettingsField[] $episode
|
||||
*/
|
||||
class Settings extends ManifestObject
|
||||
{
|
||||
protected const VALIDATION_RULES = [
|
||||
'general' => 'permit_empty|is_list',
|
||||
'podcast' => 'permit_empty|is_list',
|
||||
'episode' => 'permit_empty|is_list',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string,array{string}|string>
|
||||
*/
|
||||
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 = [];
|
||||
}
|
37
modules/Plugins/Manifest/SettingsField.php
Normal file
37
modules/Plugins/Manifest/SettingsField.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Plugins\Manifest;
|
||||
|
||||
/**
|
||||
* @property 'text'|'email'|'url'|'markdown'|'number'|'switch' $type
|
||||
* @property string $key
|
||||
* @property string $label
|
||||
* @property string $hint
|
||||
* @property string $helper
|
||||
* @property bool $optional
|
||||
*/
|
||||
class SettingsField extends ManifestObject
|
||||
{
|
||||
protected const VALIDATION_RULES = [
|
||||
'type' => '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;
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
<article class="flex flex-col p-4 rounded-xl relative bg-elevated border-3 <?= $plugin->isActive() ? 'border-accent-base' : 'border-subtle' ?>">
|
||||
<?php if ($plugin->settings['general'] !== []): ?>
|
||||
<?php if ($plugin->getSettings() !== []): ?>
|
||||
<?php // @icon('equalizer-fill')?>
|
||||
<IconButton class="absolute top-0 right-0 mt-4 mr-4" uri="<?= route_to('plugins-general-settings', $plugin->getKey()) ?>" glyph="equalizer-fill"><?= lang('Plugins.settings', [
|
||||
'pluginName' => $plugin->getName(),
|
||||
]) ?></IconButton>
|
||||
<?php endif; ?>
|
||||
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>">
|
||||
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->getIconSrc() ?>">
|
||||
<div class="flex flex-col items-start mt-2">
|
||||
<h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->version ?></span></h2>
|
||||
<h2 class="flex items-center text-xl font-bold font-display gap-x-2"><?= $plugin->getName() ?><span class="px-1 font-mono text-xs rounded-full bg-subtle"><?= $plugin->getVersion() ?></span></h2>
|
||||
<p class="font-mono text-xs tracking-wide bg-gray-100"><a href="<?= route_to('plugins-vendor', $plugin->getVendor()) ?>" class="underline underline-offset-2 decoration-2 decoration-dotted hover:decoration-solid decoration-accent"><?= $plugin->getVendor() ?></a>/<?= $plugin->getPackage() ?></p>
|
||||
<p class="mt-2 text-gray-600"><?= $plugin->getDescription() ?></p>
|
||||
</div>
|
||||
<footer class="flex items-center justify-between mt-4">
|
||||
<a href="<?= $plugin->homepage ?>" class="inline-flex items-center text-sm font-semibold underline hover:no-underline gap-x-1" target="_blank" rel="noopener noreferrer"><?= icon('link', [
|
||||
<a href="<?= $plugin->getHomepage() ?>" 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>
|
||||
<div class="flex gap-x-2">
|
||||
|
@ -1,13 +1,13 @@
|
||||
<form method="POST" action="<?= $action ?>" class="flex flex-col max-w-sm gap-4" >
|
||||
<?= csrf_field() ?>
|
||||
<?php foreach ($plugin->settings[$type] as $field): ?>
|
||||
<?php foreach ($plugin->getSettingsFields($type) as $field): ?>
|
||||
<Forms.Field
|
||||
name="<?= esc($field['key']) ?>"
|
||||
label="<?= esc($field['label']) ?>"
|
||||
hint="<?= esc($field['hint']) ?>"
|
||||
helper="<?= esc($field['helper']) ?>"
|
||||
required="<?= $field['optional'] === 'true' ? 'false' : 'true' ?>"
|
||||
value="<?= get_plugin_option($plugin->getKey(), $field['key'], $context) ?>"
|
||||
name="<?= esc($field->key) ?>"
|
||||
label="<?= esc($field->label) ?>"
|
||||
hint="<?= esc($field->hint) ?>"
|
||||
helper="<?= esc($field->helper) ?>"
|
||||
required="<?= $field->optional ? 'false' : 'true' ?>"
|
||||
value="<?= get_plugin_option($plugin->getKey(), $field->key, $context) ?>"
|
||||
/>
|
||||
<?php endforeach; ?>
|
||||
<Button class="self-end mt-4" variant="primary" type="submit"><?= lang('Common.forms.save') ?></Button>
|
||||
|
Loading…
x
Reference in New Issue
Block a user