Compare commits

...

3 Commits

Author SHA1 Message Date
Yassine Doghri 7bdde9a4f5 build: fix stylelint issues on Choices.css file 2024-05-05 17:16:20 +00:00
Yassine Doghri 688090aa82 feat(plugins): add json schema definition for plugin manifest 2024-05-05 17:10:20 +00:00
Yassine Doghri d321d0f93c refactor(plugins): redefine plugins folder structure to vendor/package 2024-05-05 13:10:59 +00:00
14 changed files with 415 additions and 100 deletions

View File

@ -30,7 +30,13 @@
"spark": "php",
"env": "dotenv",
".rsync-filter": "diff"
}
},
"json.schemas": [
{
"fileMatch": ["plugins/**/manifest.json"],
"url": "/workspaces/castopod/modules/Plugins/manifest.schema.json"
}
]
},
"extensions": [
"bmewburn.vscode-intelephense-client",

View File

@ -114,14 +114,28 @@ class Autoload extends AutoloadConfig
public function __construct()
{
// load plugins namespaces
$pluginsPaths = glob(PLUGINS_PATH . '*', GLOB_ONLYDIR | GLOB_NOSORT);
$pluginsPaths = glob(PLUGINS_PATH . '*/*', GLOB_ONLYDIR | GLOB_NOSORT);
if (! $pluginsPaths) {
$pluginsPaths = [];
}
foreach ($pluginsPaths as $pluginPath) {
$this->psr4[sprintf('Plugins\%s', basename($pluginPath))] = $pluginPath;
$vendor = basename(dirname($pluginPath));
$package = basename($pluginPath);
// validate plugin pattern
if (preg_match('~' . PLUGINS_KEY_PATTERN . '~', $vendor . '/' . $package) === false) {
continue;
}
$pluginNamespace = 'Plugins\\' . str_replace(
' ',
'',
ucwords(str_replace(['-', '_', '.'], ' ', $vendor . '\\' . $package))
);
$this->psr4[$pluginNamespace] = $pluginPath;
}
parent::__construct();

View File

@ -38,6 +38,9 @@ defined('APP_NAMESPACE') || define('APP_NAMESPACE', 'App');
defined('PLUGINS_PATH') ||
define('PLUGINS_PATH', ROOTPATH . 'plugins' . DIRECTORY_SEPARATOR);
defined('PLUGINS_KEY_PATTERN') ||
define('PLUGINS_KEY_PATTERN', '[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*');
/*
| --------------------------------------------------------------------------
| Composer Path

View File

@ -1 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><path d="M16.455 10.658c0 1.09-.705 2.244-2.098 3.443a13.447 13.447 0 0 1-2.381 1.62 14.102 14.102 0 0 1-2.381-1.629c-1.4-1.2-2.106-2.353-2.106-3.443 0-1.346.887-2.298 2.106-2.298.558 0 .888.054 1.263.238.22.1.412.237.577.403l.541.55.532-.55a2.07 2.07 0 0 1 .586-.421c.366-.166.678-.22 1.254-.22 1.209.018 2.107.988 2.107 2.307Z" clip-rule="evenodd"/><path d="M22 3.915v16.17A1.911 1.911 0 0 1 20.085 22H3.915A1.911 1.911 0 0 1 2 20.085V3.915C2 2.855 2.854 2 3.915 2h8.066c.267 0 .503.107.68.282a.986.986 0 0 1 .281.68v2.807a.962.962 0 0 1-1.922 0V3.923H4.83a.919.919 0 0 0-.916.916v14.346c0 .51.413.915.916.915h14.346a.914.914 0 0 0 .916-.915V4.839a.942.942 0 0 0-.275-.649s-.214-.206-.48-.252c-.032-.008-.07-.008-.07-.008-.03 0-.061-.007-.09-.007h-2.504a.967.967 0 0 1-.96-.962c0-.526.434-.961.96-.961h3.41A1.907 1.907 0 0 1 22 3.915Z"/></svg>
<svg fill="currentColor" viewBox="0 0 24 24" width="1em" height="1em"><path d="M16.455 10.658c0 1.09-.705 2.244-2.098 3.443a13.447 13.447 0 0 1-2.381 1.62 14.102 14.102 0 0 1-2.381-1.629c-1.4-1.2-2.106-2.353-2.106-3.443 0-1.346.887-2.298 2.106-2.298.558 0 .888.054 1.263.238.22.1.412.237.577.403l.541.55.532-.55a2.07 2.07 0 0 1 .586-.421c.366-.166.678-.22 1.254-.22 1.209.018 2.107.988 2.107 2.307Z" clip-rule="evenodd"/><path d="M22 3.915v16.17A1.911 1.911 0 0 1 20.085 22H3.915A1.911 1.911 0 0 1 2 20.085V3.915C2 2.855 2.854 2 3.915 2h8.066c.267 0 .503.107.68.282a.986.986 0 0 1 .281.68v2.807a.962.962 0 0 1-1.922 0V3.923H4.83a.919.919 0 0 0-.916.916v14.346c0 .51.413.915.916.915h14.346a.914.914 0 0 0 .916-.915V4.839a.942.942 0 0 0-.275-.649s-.214-.206-.48-.252c-.032-.008-.07-.008-.07-.008-.03 0-.061-.007-.09-.007h-2.504a.967.967 0 0 1-.96-.962c0-.526.434-.961.96-.961h3.41A1.907 1.907 0 0 1 22 3.915Z"/></svg>

Before

Width:  |  Height:  |  Size: 915 B

After

Width:  |  Height:  |  Size: 915 B

View File

@ -1 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2m4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19c-.14.75-.42 1-.68 1.03c-.58.05-1.02-.38-1.58-.75c-.88-.58-1.38-.94-2.23-1.5c-.99-.65-.35-1.01.22-1.59c.15-.15 2.71-2.48 2.76-2.69a.2.2 0 0 0-.05-.18c-.06-.05-.14-.03-.21-.02c-.09.02-1.49.95-4.22 2.79c-.4.27-.76.41-1.08.4c-.36-.01-1.04-.2-1.55-.37c-.63-.2-1.12-.31-1.08-.66c.02-.18.27-.36.74-.55c2.92-1.27 4.86-2.11 5.83-2.51c2.78-1.16 3.35-1.36 3.73-1.36c.08 0 .27.02.39.12c.1.08.13.19.14.27c-.01.06.01.24 0 .38"/></svg>
<svg fill="currentColor" viewBox="0 0 24 24" width="1em" height="1em"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2m4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19c-.14.75-.42 1-.68 1.03c-.58.05-1.02-.38-1.58-.75c-.88-.58-1.38-.94-2.23-1.5c-.99-.65-.35-1.01.22-1.59c.15-.15 2.71-2.48 2.76-2.69a.2.2 0 0 0-.05-.18c-.06-.05-.14-.03-.21-.02c-.09.02-1.49.95-4.22 2.79c-.4.27-.76.41-1.08.4c-.36-.01-1.04-.2-1.55-.37c-.63-.2-1.12-.31-1.08-.66c.02-.18.27-.36.74-.55c2.92-1.27 4.86-2.11 5.83-2.51c2.78-1.16 3.35-1.36 3.73-1.36c.08 0 .27.02.39.12c.1.08.13.19.14.27c-.01.06.01.24 0 .38"/></svg>

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 622 B

View File

@ -83,8 +83,7 @@
height: 0;
width: 0;
border-style: solid;
border-color: hsl(var(--color-text-muted)) transparent transparent
transparent;
border-color: hsl(var(--color-text-muted)) transparent transparent;
border-width: 5px;
position: absolute;
right: 11.5px;
@ -94,8 +93,7 @@
}
.choices[data-type*="select-one"].is-open::after {
border-color: transparent transparent hsl(var(--color-text-muted))
transparent;
border-color: transparent transparent hsl(var(--color-text-muted));
margin-top: -7.5px;
}

View File

@ -11,6 +11,7 @@ use CodeIgniter\I18n\Time;
use RuntimeException;
/**
* @property string $key
* @property string $name
* @property string $description
* @property string $version
@ -29,12 +30,13 @@ abstract class BasePlugin implements PluginInterface
protected bool $active;
public function __construct(
protected string $key,
protected string $filePath
protected string $vendor,
protected string $package,
protected string $directory
) {
$pluginDirectory = dirname($filePath);
$this->key = sprintf('%s/%s', $vendor, $package);
$manifest = $this->loadManifest($pluginDirectory . '/manifest.json');
$manifest = $this->loadManifest($directory . '/manifest.json');
foreach ($manifest as $key => $value) {
$this->{$key} = $value;
@ -43,7 +45,7 @@ abstract class BasePlugin implements PluginInterface
// check that plugin is active
$this->active = get_plugin_option($this->key, 'active') ?? false;
$this->iconSrc = $this->loadIcon($pluginDirectory . '/icon.svg');
$this->iconSrc = $this->loadIcon($directory . '/icon.svg');
}
/**
@ -51,7 +53,7 @@ abstract class BasePlugin implements PluginInterface
*/
public function __set(string $name, array|string $value): void
{
$this->{$name} = $name === 'releaseDate' ? Time::createFromFormat('Y-m-d', $value) : $value;
$this->{$name} = $value;
}
public function init(): void
@ -88,6 +90,16 @@ abstract class BasePlugin implements PluginInterface
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);
@ -149,14 +161,17 @@ abstract class BasePlugin implements PluginInterface
if (array_key_exists('settings', $manifest)) {
$fieldRules = [
'key' => 'required|alpha_numeric',
'name' => 'required|max_length[32]',
'description' => 'permit_empty|max_length[128]',
'key' => 'required|alpha_numeric',
'label' => 'required|max_length[32]',
'hint' => 'permit_empty|max_length[128]',
'helper' => 'permit_empty|max_length[128]',
];
$defaultField = [
'key' => '',
'name' => '',
'description' => '',
'key' => '',
'label' => '',
'hint' => '',
'helper' => '',
'optional' => false,
];
$validation->setRules($fieldRules);
foreach ($manifest['settings'] as $key => $settings) {
@ -173,14 +188,12 @@ abstract class BasePlugin implements PluginInterface
$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-]+)*))?$/]',
'compatible' => 'required|in_list[1.0]',
'description' => 'max_length[128]',
'releaseDate' => 'valid_date[Y-m-d]',
'license' => 'in_list[MIT]',
'author.name' => 'permit_empty|max_length[32]',
'author.email' => 'permit_empty|valid_email',
'author.url' => 'permit_empty|valid_url_strict',
'website' => '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',
@ -194,10 +207,9 @@ abstract class BasePlugin implements PluginInterface
$defaultAttributes = [
'description' => '',
'releaseDate' => '',
'license' => '',
'author' => [],
'website' => '',
'homepage' => '',
'hooks' => [],
'keywords' => [],
'settings' => [

View File

@ -6,6 +6,7 @@ namespace Modules\Plugins\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Modules\Plugins\Plugins;
class UninstallPlugin extends BaseCommand
{
@ -51,18 +52,20 @@ class UninstallPlugin extends BaseCommand
*/
public function run(array $pluginKeys): int
{
$validation = service('validation');
/** @var Plugins $plugins */
$plugins = service('plugins');
/** @var list<string> $errors */
$errors = [];
foreach ($pluginKeys as $pluginKey) {
// TODO: change validation of pluginKey
if (! $validation->check($pluginKey, 'required')) {
$errors = [...$errors, ...$validation->getErrors()];
$plugin = $plugins->getPluginByKey($pluginKey);
if ($plugin === null) {
$errors[] = sprintf('Plugin %s was not found.', $pluginKey);
continue;
}
if (! service('plugins')->uninstall($pluginKey)) {
if (! $plugins->uninstall($plugin)) {
$errors[] = sprintf('Something happened when removing %s', $pluginKey);
}
}

View File

@ -5,6 +5,9 @@ declare(strict_types=1);
use CodeIgniter\Router\RouteCollection;
/** @var RouteCollection $routes */
$routes->addPlaceholder('pluginVendor', '[a-z0-9]([_.-]?[a-z0-9]+)*');
$routes->addPlaceholder('pluginKey', PLUGINS_KEY_PATTERN);
$routes->group(
config('Admin')
->gateway,
@ -17,44 +20,50 @@ $routes->group(
'as' => 'plugins-installed',
'filter' => 'permission:plugins.manage',
]);
$routes->get('(:segment)', 'PluginController::generalSettings/$1', [
'as' => 'plugins-general-settings',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)', 'PluginController::generalSettingsAction/$1', [
'as' => 'plugins-general-settings-action',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)/activate', 'PluginController::activate/$1', [
'as' => 'plugins-activate',
'filter' => 'permission:plugins.manage',
]);
$routes->post('(:segment)/deactivate', 'PluginController::deactivate/$1', [
'as' => 'plugins-deactivate',
'filter' => 'permission:plugins.manage',
]);
// TODO: change to delete
$routes->get('(:segment)/uninstall', 'PluginController::uninstall/$1', [
'as' => 'plugins-uninstall',
$routes->get('(:pluginVendor)', 'PluginController::vendor/$1', [
'as' => 'plugins-vendor',
'filter' => 'permission:plugins.manage',
]);
$routes->group('(:pluginKey)', static function ($routes): void {
$routes->get('/', 'PluginController::generalSettings/$1/$2', [
'as' => 'plugins-general-settings',
'filter' => 'permission:plugins.manage',
]);
$routes->post('/', 'PluginController::generalSettingsAction/$1/$2', [
'as' => 'plugins-general-settings-action',
'filter' => 'permission:plugins.manage',
]);
$routes->post('activate', 'PluginController::activate/$1/$2', [
'as' => 'plugins-activate',
'filter' => 'permission:plugins.manage',
]);
$routes->post('deactivate', 'PluginController::deactivate/$1/$2', [
'as' => 'plugins-deactivate',
'filter' => 'permission:plugins.manage',
]);
// TODO: change to delete
$routes->get('uninstall', 'PluginController::uninstall/$1/$2', [
'as' => 'plugins-uninstall',
'filter' => 'permission:plugins.manage',
]);
});
});
$routes->group('podcasts/(:num)/plugins', static function ($routes): void {
$routes->get('(:segment)', 'PluginController::podcastSettings/$1/$2', [
$routes->get('(:pluginKey)', 'PluginController::podcastSettings/$1/$2/$3', [
'as' => 'plugins-podcast-settings',
'filter' => 'permission:podcast#.edit',
]);
$routes->post('(:segment)', 'PluginController::podcastSettingsAction/$1/$2', [
$routes->post('(:pluginKey)', 'PluginController::podcastSettingsAction/$1/$2/$3', [
'as' => 'plugins-podcast-settings-action',
'filter' => 'permission:podcast#.edit',
]);
});
$routes->group('podcasts/(:num)/episodes/(:num)/plugins', static function ($routes): void {
$routes->get('(:segment)', 'PluginController::episodeSettings/$1/$2/$3', [
$routes->get('(:pluginKey)', 'PluginController::episodeSettings/$1/$2/$3/$4', [
'as' => 'plugins-episode-settings',
'filter' => 'permission:podcast#.edit',
]);
$routes->post('(:segment)', 'PluginController::episodeSettingsAction/$1/$2/$3', [
$routes->post('(:pluginKey)', 'PluginController::episodeSettingsAction/$1/$2/$3/$4', [
'as' => 'plugins-episode-settings-action',
'filter' => 'permission:podcast#.edit',
]);

View File

@ -35,12 +35,25 @@ class PluginController extends BaseController
]);
}
public function generalSettings(string $pluginKey): string
public function vendor(string $vendor): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$vendorPlugins = $plugins->getVendorPlugins($vendor);
return view('plugins/installed', [
'total' => count($vendorPlugins),
'plugins' => $vendorPlugins,
'pager_links' => '',
]);
}
public function generalSettings(string $vendor, string $package): string
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -52,12 +65,12 @@ class PluginController extends BaseController
]);
}
public function generalSettingsAction(string $pluginKey): RedirectResponse
public function generalSettingsAction(string $vendor, string $package): RedirectResponse
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -66,7 +79,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['general'] as $option) {
$optionKey = $option['key'];
$optionValue = $this->request->getPost($optionKey);
$plugins->setOption($pluginKey, $optionKey, $optionValue);
$plugins->setOption($plugin, $optionKey, $optionValue);
}
return redirect()->back()
@ -75,7 +88,7 @@ class PluginController extends BaseController
]));
}
public function podcastSettings(string $podcastId, string $pluginKey): string
public function podcastSettings(string $podcastId, string $vendor, string $package): string
{
$podcast = (new PodcastModel())->getPodcastById((int) $podcastId);
@ -86,7 +99,7 @@ class PluginController extends BaseController
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -102,12 +115,12 @@ class PluginController extends BaseController
]);
}
public function podcastSettingsAction(string $podcastId, string $pluginKey): RedirectResponse
public function podcastSettingsAction(string $podcastId, string $vendor, string $package): RedirectResponse
{
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -116,7 +129,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['podcast'] as $setting) {
$settingKey = $setting['key'];
$settingValue = $this->request->getPost($settingKey);
$plugins->setOption($pluginKey, $settingKey, $settingValue, ['podcast', (int) $podcastId]);
$plugins->setOption($plugin, $settingKey, $settingValue, ['podcast', (int) $podcastId]);
}
return redirect()->back()
@ -125,7 +138,7 @@ class PluginController extends BaseController
]));
}
public function episodeSettings(string $podcastId, string $episodeId, string $pluginKey): string
public function episodeSettings(string $podcastId, string $episodeId, string $vendor, string $package): string
{
$episode = (new EpisodeModel())->getEpisodeById((int) $episodeId);
@ -136,7 +149,7 @@ class PluginController extends BaseController
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -154,12 +167,16 @@ class PluginController extends BaseController
]);
}
public function episodeSettingsAction(string $podcastId, string $episodeId, string $pluginKey): RedirectResponse
{
public function episodeSettingsAction(
string $podcastId,
string $episodeId,
string $vendor,
string $package
): RedirectResponse {
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($pluginKey);
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
@ -168,7 +185,7 @@ class PluginController extends BaseController
foreach ($plugin->settings['episode'] as $setting) {
$settingKey = $setting['key'];
$settingValue = $this->request->getPost($settingKey);
$plugins->setOption($pluginKey, $settingKey, $settingValue, ['episode', (int) $episodeId]);
$plugins->setOption($plugin, $settingKey, $settingValue, ['episode', (int) $episodeId]);
}
return redirect()->back()
@ -177,23 +194,50 @@ class PluginController extends BaseController
]));
}
public function activate(string $pluginKey): RedirectResponse
public function activate(string $vendor, string $package): RedirectResponse
{
service('plugins')->activate($pluginKey);
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->activate($plugin);
return redirect()->back();
}
public function deactivate(string $pluginKey): RedirectResponse
public function deactivate(string $vendor, string $package): RedirectResponse
{
service('plugins')->deactivate($pluginKey);
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->deactivate($plugin);
return redirect()->back();
}
public function uninstall(string $pluginKey): RedirectResponse
public function uninstall(string $vendor, string $package): RedirectResponse
{
service('plugins')->uninstall($pluginKey);
/** @var Plugins $plugins */
$plugins = service('plugins');
$plugin = $plugins->getPlugin($vendor, $package);
if ($plugin === null) {
throw PageNotFoundException::forPageNotFound();
}
$plugins->uninstall($plugin);
return redirect()->back();
}

View File

@ -28,6 +28,11 @@ class Plugins
*/
protected static array $plugins = [];
/**
* @var array<string,BasePlugin[]>
*/
protected static array $pluginsByVendor = [];
protected static int $installedCount = 0;
public function __construct()
@ -100,10 +105,18 @@ class Plugins
return $pluginsWithEpisodeSettings;
}
public function getPlugin(string $key): ?BasePlugin
/**
* @return array<BasePlugin>
*/
public function getVendorPlugins(string $vendor): array
{
foreach (static::$plugins as $plugin) {
if ($plugin->getKey() === $key) {
return static::$pluginsByVendor[$vendor] ?? [];
}
public function getPlugin(string $vendor, string $package): ?BasePlugin
{
foreach ($this->getVendorPlugins($vendor) as $plugin) {
if ($plugin->getKey() === $vendor . '/' . $package) {
return $plugin;
}
}
@ -111,6 +124,16 @@ class Plugins
return null;
}
public function getPluginByKey(string $key): ?BasePlugin
{
if (! str_contains('/', $key)) {
return null;
}
$keyArray = explode('/', $key);
return $this->getPlugin($keyArray[0], $keyArray[1]);
}
/**
* @param value-of<static::HOOKS> $name
* @param array<mixed> $arguments
@ -131,22 +154,22 @@ class Plugins
}
}
public function activate(string $pluginKey): void
public function activate(BasePlugin $plugin): void
{
set_plugin_option($pluginKey, 'active', true);
set_plugin_option($plugin->getKey(), 'active', true);
}
public function deactivate(string $pluginKey): void
public function deactivate(BasePlugin $plugin): void
{
set_plugin_option($pluginKey, 'active', false);
set_plugin_option($plugin->getKey(), 'active', false);
}
/**
* @param ?array{'podcast'|'episode',int} $additionalContext
*/
public function setOption(string $pluginKey, string $name, mixed $value, array $additionalContext = null): void
public function setOption(BasePlugin $plugin, string $name, mixed $value, array $additionalContext = null): void
{
set_plugin_option($pluginKey, $name, $value, $additionalContext);
set_plugin_option($plugin->getKey(), $name, $value, $additionalContext);
}
public function getInstalledCount(): int
@ -154,7 +177,7 @@ class Plugins
return static::$installedCount;
}
public function uninstall(string $pluginKey): bool
public function uninstall(BasePlugin $plugin): bool
{
// remove all settings data
$db = Database::connect();
@ -162,7 +185,7 @@ class Plugins
$db->transStart();
$builder->where('class', self::class);
$builder->like('context', sprintf('plugin:%s', $pluginKey . '%'));
$builder->like('context', sprintf('plugin:%s', $plugin->getKey() . '%'));
if (! $builder->delete()) {
$db->transRollback();
@ -170,7 +193,7 @@ class Plugins
}
// delete plugin folder from PLUGINS_PATH
$pluginFolder = PLUGINS_PATH . $pluginKey;
$pluginFolder = PLUGINS_PATH . $plugin->getKey();
$rmdirResult = $this->rrmdir($pluginFolder);
$transResult = $db->transCommit();
@ -181,27 +204,36 @@ class Plugins
protected function registerPlugins(): void
{
// search for plugins in plugins folder
// TODO: only get directories? Should be organized as author/repo?
$pluginsFiles = glob(PLUGINS_PATH . '**/Plugin.php');
$pluginsDirectories = glob(PLUGINS_PATH . '*/*', GLOB_ONLYDIR);
if (! $pluginsFiles) {
if ($pluginsDirectories === false || $pluginsDirectories === []) {
return;
}
$locator = service('locator');
foreach ($pluginsFiles as $file) {
$className = $locator->findQualifiedNameFromPath($file);
foreach ($pluginsDirectories as $pluginDirectory) {
$vendor = basename(dirname($pluginDirectory));
$package = basename($pluginDirectory);
if (preg_match('~' . PLUGINS_KEY_PATTERN . '~', $vendor . '/' . $package) === false) {
continue;
}
$pluginFile = $pluginDirectory . DIRECTORY_SEPARATOR . 'Plugin.php';
$className = $locator->findQualifiedNameFromPath($pluginFile);
if ($className === false) {
continue;
}
$plugin = new $className(basename(dirname($file)), $file);
$plugin = new $className($vendor, $package, $pluginDirectory);
if (! $plugin instanceof BasePlugin) {
continue;
}
static::$plugins[] = $plugin;
static::$pluginsByVendor[$vendor][] = $plugin;
++static::$installedCount;
}
}

View File

@ -0,0 +1,191 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/schemas/manifest.json",
"title": "JSON schema for Castopod Plugins's manifest.json files",
"description": "The Castopod plugin manifest defines both metadata and behavior of a plugin",
"type": "object",
"properties": {
"name": {
"description": "The plugin name, including 'vendor-name/' prefix",
"type": "string",
"pattern": "^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9]([_.-]?[a-z0-9]+)*$",
"examples": ["acme/hello-world"]
},
"version": {
"description": "The plugin's semantic version. See https://semver.org/",
"type": "string",
"pattern": "^(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-]+)*))?$",
"examples": ["1.0.0"]
},
"description": {
"description": "This helps people discover your plugin as it's listed in repositories",
"type": "string"
},
"author": {
"$ref": "#/$defs/person"
},
"authors": {
"type": "array",
"items": {
"$ref": "#/$defs/person"
}
},
"homepage": {
"description": "The URL to the plugin homepage",
"type": "string",
"format": "uri"
},
"license": {
"description": "You should specify a license for your plugin so that people know how they are permitted to use it, and any restrictions you're placing on it.",
"default": "UNLICENSED",
"anyOf": [
{
"type": "string"
},
{
"enum": [
"AGPL-3.0-only",
"AGPL-3.0-or-later",
"Apache-2.0",
"BSL-1.0",
"GPL-3.0-only",
"GPL-3.0-or-later",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"MIT",
"MPL-2.0",
"Unlicense",
"UNLICENSED"
]
}
]
},
"private": {
"type": "boolean",
"description": "If set to true, then repositories should refuse to publish it."
},
"keywords": {
"description": "This helps people discover your plugin as it's listed in repositories",
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"enum": [
"accessibility",
"analytics",
"monetization",
"podcasting2",
"privacy",
"seo"
]
}
]
},
"uniqueItems": true
},
"hooks": {
"description": "The hooks used by the plugin.",
"type": "array",
"items": {
"enum": ["channelTag", "itemTag", "siteHead"]
},
"uniqueItems": true
},
"settings": {
"type": "object",
"properties": {
"general": {
"type": "array",
"items": {
"$ref": "#/$defs/settings-field"
}
},
"podcast": {
"type": "array",
"items": {
"$ref": "#/$defs/settings-field"
}
},
"episode": {
"type": "array",
"items": {
"$ref": "#/$defs/settings-field"
}
}
}
},
"files": {
"description": "List of files to include in your plugin package. If you include a folder in the array, all files inside it will also be included.",
"type": "array",
"items": {
"type": "string"
}
},
"repository": {
"description": "Specify the place where your plugin code lives. This is helpful for people who want to contribute.",
"type": ["object", "string"],
"properties": {
"type": {
"type": "string"
},
"url": {
"type": "string"
},
"directory": {
"type": "string"
}
}
}
},
"required": ["name", "version"],
"additionalProperties": false,
"$defs": {
"person": {
"description": "A person who has been involved in creating or maintaining this plugin.",
"type": ["object", "string"],
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"url": {
"type": "string",
"format": "uri"
}
}
},
"settings-field": {
"type": "object",
"properties": {
"type": {
"enum": ["text", "email", "url", "markdown", "number", "switch"],
"default": "text"
},
"key": {
"type": "string"
},
"label": {
"type": "string"
},
"hint": {
"type": "string"
},
"helper": {
"type": "string"
},
"optional": {
"type": "boolean"
}
},
"required": ["key", "label"],
"additionalProperties": false
}
}
}

View File

@ -6,12 +6,13 @@
]) ?></IconButton>
<?php endif; ?>
<img class="rounded-full min-w-16 max-w-16 aspect-square" src="<?= $plugin->iconSrc ?>">
<div class="flex flex-col mt-2">
<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>
<p class="text-gray-600"><?= $plugin->getDescription() ?></p>
<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->website ?>" 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->homepage ?>" 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">

View File

@ -2,9 +2,11 @@
<?= csrf_field() ?>
<?php foreach ($plugin->settings[$type] as $field): ?>
<Forms.Field
name="<?= $field['key'] ?>"
label="<?= $field['name'] ?>"
hint="<?= $field['description'] ?>"
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) ?>"
/>
<?php endforeach; ?>