refactor(plugins): redefine plugins folder structure to vendor/package

This commit is contained in:
Yassine Doghri 2024-05-05 13:10:59 +00:00
parent 1ff91a8546
commit d321d0f93c
8 changed files with 195 additions and 77 deletions

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

@ -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');
}
/**
@ -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);

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

@ -6,9 +6,10 @@
]) ?></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', [