From c25db6aaac0a7dc553513f8cae7336ed1931ac85 Mon Sep 17 00:00:00 2001 From: David Siegfried <david.siegfried@uni-vechta.de> Date: Fri, 6 Sep 2024 13:17:34 +0000 Subject: [PATCH] add make plugin, fixes #4563 Closes #4563 Merge request studip/studip!3374 --- RELEASE-NOTES.md | 5 +- cli/Commands/Make/Migration.php | 3 +- cli/Commands/Make/Model.php | 3 +- cli/Commands/Make/Plugin.php | 374 +++++++++++++++++++++++ cli/Commands/Make/StudipClassPrinter.php | 18 -- cli/studip | 1 + 6 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 cli/Commands/Make/Plugin.php delete mode 100644 cli/Commands/Make/StudipClassPrinter.php diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 7e73a33983a..74c237ac1bc 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -67,8 +67,9 @@ - Die Klassen `DbView`, `DbSnapshot` und die zugehörigen Dateien in `lib/dbviews` wurden ausgebaut. ([Issue #4390](https://gitlab.studip.de/studip/studip/-/issues/4390)) - Als Ersatz dienen Datenbankabfragen mittels der `DBManager`-Klasse oder mittels `SimpleORMap`-Modellen. - Es wurden zwei neue CLI-Kommandos hinzugefügt, womit man Klassenrümpfe für SORM-Models und Migrationen erstellen kann. Bei den Migrationen wird die Versionsnummer für die jeweilige `domain` automatisch ermittelt. - - `cli/studip make:model` und `cli/studip make:migration`. - + - `cli/studip make:model` und `cli/studip make:migration`. +- Es wurde ein neues CLI-Kommando hinzugefügt, womit man auf einfache Weise ein Plugin-Grundgerüst erstellen kann. + - `cli/studip make:plugin` ## Security related issues - diff --git a/cli/Commands/Make/Migration.php b/cli/Commands/Make/Migration.php index d9ae1d16172..992b02e8f5b 100644 --- a/cli/Commands/Make/Migration.php +++ b/cli/Commands/Make/Migration.php @@ -3,6 +3,7 @@ namespace Studip\Cli\Commands\Make; use Nette\PhpGenerator\PhpFile; +use Nette\PhpGenerator\PsrPrinter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -96,7 +97,7 @@ final class Migration extends Command $class->addMethod('up')->addBody('// Add content'); $class->addMethod('down')->addBody('// Add content'); - $printer = new StudipClassPrinter(); + $printer = new PsrPrinter(); $result = $printer->printFile($file); $migrationName = $version . '_' . str_replace(' ', '_', lcfirst($name)); $filename = $path . '/' . $migrationName . '.php'; diff --git a/cli/Commands/Make/Model.php b/cli/Commands/Make/Model.php index 57b15af7432..82e683bef6e 100644 --- a/cli/Commands/Make/Model.php +++ b/cli/Commands/Make/Model.php @@ -3,6 +3,7 @@ namespace Studip\Cli\Commands\Make; use Nette\PhpGenerator\PhpFile; +use Nette\PhpGenerator\PsrPrinter; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\ArrayInput; @@ -89,7 +90,7 @@ final class Model extends Command $method->addParameter('config', []); - $printer = new StudipClassPrinter(); + $printer = new PsrPrinter(); $result = $printer->printFile($file); $modelName = str_replace(' ', '_', ucfirst($name)); diff --git a/cli/Commands/Make/Plugin.php b/cli/Commands/Make/Plugin.php new file mode 100644 index 00000000000..f361f342f12 --- /dev/null +++ b/cli/Commands/Make/Plugin.php @@ -0,0 +1,374 @@ +<?php + +namespace Studip\Cli\Commands\Make; + +use Nette\PhpGenerator\PhpFile; +use Nette\PhpGenerator\PsrPrinter; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +final class Plugin extends Command +{ + private const VALID_PLUGIN_INTERFACES = [ + \SystemPlugin::class, + \StandardPlugin::class, + \AdminCourseAction::class, + \AdminCourseContents::class, + \AdminCourseWidgetPlugin::class, + \AdministrationPlugin::class, + \DetailspagePlugin::class, + \ExternPagePlugin::class, + \FileSystemPlugin::class, + \FileUploadHook::class, + \ForumModule::class, + \HomepagePlugin::class, + \LibraryPlugin::class, + \MetricsPlugin::class, + \PortalPlugin::class, + \PrivacyPlugin::class, + \ScorePlugin::class, + \QuestionnaireAssignmentPlugin::class, + ]; + + protected static $defaultName = 'make:plugin'; + + protected function configure(): void + { + $this->addArgument('name', InputArgument::OPTIONAL, 'Name of the plugin'); + $this->addOption('origin', 'o', InputOption::VALUE_OPTIONAL, 'Origin of the plugin'); + $this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Description of the plugin'); + $this->addOption('plugin-version', 'pv', InputOption::VALUE_OPTIONAL, 'Version of the plugin'); + $this->addOption('min-version', 'min', InputOption::VALUE_OPTIONAL, 'Minimum version of Stud.IP the plugin supports'); + $this->addOption('max-version', 'max', InputOption::VALUE_OPTIONAL, 'Maximum version of Stud.IP the plugin supports'); + $this->addOption('plugin-interfaces', 'I', InputOption::VALUE_OPTIONAL, 'Comma separated list of plugin interfaces'); + $this->addOption('with-controller', 'c', InputOption::VALUE_OPTIONAL, 'Create default controller'); + $this->addOption('force', 'F', InputOption::VALUE_NEGATABLE, 'Force creation of the plugin (even if a plugin with that name and origin already exists)', false); + $this->setDescription('Create a new plain plugin frame'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $helper = $this->getHelper('question'); + + // Get name of the plugin (if not already passed via command line) + $name = $input->getArgument('name'); + if ($name === null) { + $question = new Question('Please enter the name of the plugin: '); + $question->setMaxAttempts(3); + $question->setValidator(function ($name): string { + if (!$name) { + throw new \RuntimeException('The name of the plugin is required'); + } + + return $name; + }); + $question->setTrimmable(true); + $name = $helper->ask($input, $output, $question); + } + + // Get origin of the plugin (if not already passed via command line) + $origin = $input->getOption('origin'); + if ($origin === null) { + $question = new Question('Please enter the origin of the plugin: '); + $question->setAutocompleterValues($this->getKnownOrigins()); + $question->setMaxAttempts(3); + $question->setValidator(function ($origin): string { + if (!$origin) { + throw new \RuntimeException('The origin of the plugin is required'); + } + + return $origin; + }); + $question->setTrimmable(true); + $origin = $helper->ask($input, $output, $question); + } + + $interfaces = null; + if ($input->hasOption('plugin-interfaces')) { + $interfaces = explode(',', $input->getOption('plugin-interfaces')); + $interfaces = array_filter($interfaces); + $interfaces = array_intersect($interfaces, self::VALID_PLUGIN_INTERFACES); + $interfaces = $interfaces ?: null; + } + $controllers = $input->getOption('with-controller'); + + if (!$input->getOption('no-interaction')) { + $version = $input->getOption('plugin-version'); + if ($version === null) { + $question = new Question('Please enter the version of the plugin: ', '1.0'); + $version = $helper->ask($input, $output, $question); + } + + $minVersion = $input->getOption('min-version'); + if ($minVersion === null) { + $question = new Question('Please enter the studipMinVersion of the plugin: ', ''); + $minVersion = $helper->ask($input, $output, $question); + } + + $maxVersion = $input->getOption('max-version'); + if ($maxVersion === null) { + $question = new Question('Please enter the studipMaxVersion of the plugin: ', ''); + $maxVersion = $helper->ask($input, $output, $question); + } + + $description = $input->getOption('description'); + if ($description === null) { + $question = new Question('Please enter the description of the plugin: '); + $description = $helper->ask($input, $output, $question); + } + + if ($interfaces === null) { + $question = new ChoiceQuestion( + 'Please enter the interfaces of the plugin: ', + self::VALID_PLUGIN_INTERFACES, + 0 + ); + $question->setMultiselect(true); + $interfaces = $helper->ask($input, $output, $question); + } + + if ($controllers === null) { + $question = new ConfirmationQuestion( + 'Should controller classes be created? (y/n) ', + false, + '/^(y|j)/i' + ); + $controllers = $helper->ask($input, $output, $question); + + if ($controllers) { + $question = new ConfirmationQuestion( + 'Do you want to define the controllers and actions interactively? (y/n) ', + false, + '/^(y|j)/i' + ); + if ($helper->ask($input, $output, $question)) { + $controllers = []; + + do { + $question = new Question('- Please enter the name of a controller: '); + $question->setMaxAttempts(3); + $question->setValidator(function ($controller): string { + if ($controller && preg_match('/[^a-z_]/', $controller)) { + throw new \RuntimeException('The name of the controller may only contain letters and the underscore character.'); + } + + return strtolower($controller); + }); + $question->setTrimmable(true); + $controller = $helper->ask($input, $output, $question); + + if ($controller) { + $controllers[$controller] = []; + + do { + $question = new Question('- Please enter the name of an action (use special name !crud for the action "index", "edit", "store", "delete"): '); + $question->setMaxAttempts(3); + $question->setValidator(function ($action): string { + if ($action === '!crud') { + return $action; + } + + if ($action && preg_match('/[^a-z_]/', $action)) { + throw new \RuntimeException('The name of the action may only contain letters and the underscore character.'); + } + + return strtolower($action); + }); + $question->setTrimmable(true); + $action = $helper->ask($input, $output, $question); + + if ($action === '!crud') { + $controllers[$controller][] = 'index'; + $controllers[$controller][] = 'edit'; + $controllers[$controller][] = 'store'; + $controllers[$controller][] = 'delete'; + } elseif ($action) { + $controllers[$controller][] = $action; + } + } while ($action !== ''); + } + + } while ($controller !== ''); + } + } + } + } + + // Cleanup + $className = strtopascalcase($name); + $interfaces = $interfaces ?? [\SystemPlugin::class]; + + $pluginPath = $GLOBALS['STUDIP_BASE_PATH'] . "/public/plugins_packages/$origin/$className"; + + if ( + file_exists($pluginPath) + && !$input->getOption('force') + ) { + $question = new ConfirmationQuestion( + 'There is already a plugin with that origin and name. Overwrite? (y/n) ', + false, + '/^(y|j)/i' + ); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('<error>Aborted'); + exit; + } + } + + mkdir($pluginPath, 0755, true); + mkdir("$pluginPath/controllers", 0755, true); + mkdir("$pluginPath/views", 0755, true); + mkdir("$pluginPath/lib/classes/", 0755, true); + mkdir("$pluginPath/lib/models", 0755, true); + mkdir("$pluginPath/migrations", 0755, true); + + file_put_contents( + "$pluginPath/plugin.manifest", + $this->generatePluginManifest( + $name, + $className, + $origin, + $version ?? '', + $minVersion ?? '', + $maxVersion ?? '', + $description ?? '' + ) + ); + + // Generate Plugin-Class + $file = new PhpFile(); + $class = $file->addClass($className); + $class->setExtends(\StudIPPlugin::class); + foreach ($interfaces as $interface) { + $class->addImplement($interface); + } + + $method = $class->addMethod('__construct'); + $method->addBody('parent::__construct();'); + $method = $class->addMethod('perform'); + $method->addParameter('unconsumed_path'); + $method->addBody("//Import here styles or scripts for example"); + $method->addBody('parent::perform($unconsumed_path);'); + + foreach ($interfaces as $interface) { + foreach (get_class_methods($interface) as $method) { + $class->inheritMethod($method); + }; + } + + $printer = new PsrPrinter(); + $result = $printer->printFile($file); + + // Include requiring of bootstrap + $result = str_replace( + '<?php', + '<?php' . PHP_EOL . 'require __DIR__ . \'/bootstrap.php\';' . PHP_EOL, + $result + ); + $filename = "$pluginPath/$className.php"; + file_put_contents($filename, $result); + + // Create bootstrap + $bootstrap = implode(PHP_EOL, [ + '<?php', + 'StudipAutoloader::addAutoloadPath(__DIR__ . \'/lib/classes\');', + 'StudipAutoloader::addAutoloadPath(__DIR__ . \'/lib/models\');', + ]); + file_put_contents( + "$pluginPath/bootstrap.php", + $bootstrap + ); + + if ($controllers !== null) { + $controllers = $this->createControllersAndView($controllers); + + // Create controllers and views + foreach ($controllers as $controller_name => $actions) { + $file = new PhpFile(); + $class = $file->addClass(strtopascalcase($controller_name . ' Controller')); + $class->addProperty('_autobind', true); + $class->setExtends(\PluginController::class); + + foreach ($actions as $action) { + $method = $class->addMethod("{$action}_action"); + $method->addBody('//add your code here'); + } + + $printer = new PsrPrinter(); + $result = $printer->printFile($file); + $filename = "{$pluginPath}/controllers/{$controller_name}.php"; + file_put_contents($filename, $result); + + $viewPath = "$pluginPath/views/$controller_name"; + mkdir($viewPath, 0755, true); + + foreach ($actions as $action) { + file_put_contents("{$viewPath}/{$action}.php", ''); + } + } + } + + $output->writeln('<info>Your plugin has been created!</info>'); + + return Command::SUCCESS; + } + + + private function generatePluginManifest( + string $name, + string $class_name, + string $origin, + string $version, + string $minVersion, + string $maxVersion, + string $description, + ): string { + if ($version === '') { + $version = '1.0'; + } + + $manifest = "pluginname=$name\n"; + $manifest .= "pluginclassname=$class_name\n"; + $manifest .= "origin=$origin\n"; + $manifest .= "version=$version\n"; + + if ($description) { + $manifest .= "description=$description\n"; + } + if ($minVersion) { + $manifest .= "studipMinVersion=$minVersion\n"; + } + if ($maxVersion) { + $manifest .= "studipMaxVersion=$maxVersion\n"; + } + + return $manifest; + } + + private function getKnownOrigins(): array + { + $origins = glob($GLOBALS['STUDIP_BASE_PATH'] . '/public/plugins_packages/*', GLOB_ONLYDIR); + $origins = array_map('basename', $origins); + natcasesort($origins); + return $origins; + } + + private function createControllersAndView(mixed $controllers): array + { + if ($controllers === true) { + return ['show' => ['index']]; + } + + if (is_string($controllers)) { + return [$controllers => ['index']]; + } + + return $controllers; + } +} diff --git a/cli/Commands/Make/StudipClassPrinter.php b/cli/Commands/Make/StudipClassPrinter.php deleted file mode 100644 index 5e860850ef6..00000000000 --- a/cli/Commands/Make/StudipClassPrinter.php +++ /dev/null @@ -1,18 +0,0 @@ -<?php -namespace Studip\Cli\Commands\Make; - -use Nette\PhpGenerator\Printer; - -final class StudipClassPrinter extends Printer -{ - /** length of the line after which the line will break */ - public int $wrapLength = 120; - /** indentation character, can be replaced with a sequence of spaces */ - public string $indentation = ' '; - /** number of blank lines between properties */ - public int $linesBetweenProperties = 0; - /** number of blank lines between methods */ - public int $linesBetweenMethods = 1; - - public string $returnTypeColon = ': '; -} diff --git a/cli/studip b/cli/studip index 4d19e2b1149..8effcae08c7 100755 --- a/cli/studip +++ b/cli/studip @@ -34,6 +34,7 @@ $commands = [ Commands\DB\MoveMatrikelnummer::class, Commands\Make\Migration::class, Commands\Make\Model::class, + Commands\Make\Plugin::class, Commands\DI\Reset::class, Commands\Files\Dump::class, Commands\Fix\Biest7789::class, -- GitLab