1#!/usr/bin/env php 2<?php 3 4use dokuwiki\Extension\CLIPlugin; 5use dokuwiki\Extension\PluginController; 6use dokuwiki\plugin\dev\SVGIcon; 7use splitbrain\phpcli\Exception as CliException; 8use splitbrain\phpcli\Options; 9 10/** 11 * @license GPL2 12 * @author Andreas Gohr <andi@splitbrain.org> 13 */ 14class cli_plugin_dev extends CLIPlugin 15{ 16 17 /** 18 * Register options and arguments on the given $options object 19 * 20 * @param Options $options 21 * @return void 22 */ 23 protected function setup(Options $options) 24 { 25 $options->useCompactHelp(); 26 $options->setHelp( 27 "CLI to help with DokuWiki plugin and template development.\n\n" . 28 "Run this script from within the extension's directory." 29 ); 30 31 $options->registerCommand('init', 'Initialize a new plugin or template in the current (empty) directory.'); 32 $options->registerCommand('addTest', 'Add the testing framework files and a test. (_test/)'); 33 $options->registerArgument('test', 'Optional name of the new test. Defaults to the general test.', false, 34 'addTest'); 35 $options->registerCommand('addConf', 'Add the configuration files. (conf/)'); 36 $options->registerCommand('addLang', 'Add the language files. (lang/)'); 37 38 $types = PluginController::PLUGIN_TYPES; 39 array_walk( 40 $types, 41 function (&$item) { 42 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 43 } 44 ); 45 46 $options->registerCommand('addComponent', 'Add a new plugin component.'); 47 $options->registerArgument('type', 'Type of the component. Needs to be one of ' . join(', ', $types), true, 48 'addComponent'); 49 $options->registerArgument('name', 'Optional name of the component. Defaults to a base component.', false, 50 'addComponent'); 51 52 $options->registerCommand('deletedFiles', 'Create the list of deleted files based on the git history.'); 53 $options->registerCommand('rmObsolete', 'Delete obsolete files.'); 54 55 $prefixes = array_keys(SVGIcon::SOURCES); 56 array_walk( 57 $prefixes, 58 function (&$item) { 59 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 60 } 61 ); 62 63 $options->registerCommand('downloadSvg', 'Download an SVG file from a known icon repository.'); 64 $options->registerArgument('prefix:name', 65 'Colon-prefixed name of the icon. Available prefixes: ' . join(', ', $prefixes), true, 'downloadSvg'); 66 $options->registerArgument('output', 'File to save, defaults to <name>.svg in current dir', false, 67 'downloadSvg'); 68 69 $options->registerCommand('cleanSvg', 'Clean an existing SVG file to reduce file size.'); 70 $options->registerArgument('file', 'The file to clean (will be overwritten)', true, 'cleanSvg'); 71 } 72 73 /** @inheritDoc */ 74 protected function main(Options $options) 75 { 76 $args = $options->getArgs(); 77 78 switch ($options->getCmd()) { 79 case 'init': 80 return $this->cmdInit(); 81 case 'addTest': 82 $test = array_shift($args); 83 return $this->cmdAddTest($test); 84 case 'addConf': 85 return $this->cmdAddConf(); 86 case 'addLang': 87 return $this->cmdAddLang(); 88 case 'addComponent': 89 $type = array_shift($args); 90 $component = array_shift($args); 91 return $this->cmdAddComponent($type, $component); 92 case 'deletedFiles': 93 return $this->cmdDeletedFiles(); 94 case 'rmObsolete': 95 return $this->cmdRmObsolete(); 96 case 'downloadSvg': 97 $ident = array_shift($args); 98 $save = array_shift($args); 99 return $this->cmdDownloadSVG($ident, $save); 100 case 'cleanSvg': 101 $file = array_shift($args); 102 return $this->cmdCleanSVG($file); 103 default: 104 $this->error('Unknown command'); 105 echo $options->help(); 106 return 0; 107 } 108 } 109 110 /** 111 * Get the extension name from the current working directory 112 * 113 * @throws CliException if something's wrong 114 * @param string $dir 115 * @return string[] name, type 116 */ 117 protected function getTypedNameFromDir($dir) 118 { 119 $pdir = fullpath(DOKU_PLUGIN); 120 $tdir = fullpath(tpl_incdir() . '../'); 121 122 if (strpos($dir, $pdir) === 0) { 123 $ldir = substr($dir, strlen($pdir)); 124 $type = 'plugin'; 125 } elseif (strpos($dir, $tdir) === 0) { 126 $ldir = substr($dir, strlen($tdir)); 127 $type = 'template'; 128 } else { 129 throw new CliException('Current directory needs to be in plugin or template directory'); 130 } 131 132 $ldir = trim($ldir, '/'); 133 134 if (strpos($ldir, '/') !== false) { 135 throw new CliException('Current directory has to be main extension directory'); 136 } 137 138 return [$ldir, $type]; 139 } 140 141 /** 142 * Interactively ask for a value from the user 143 * 144 * @param string $prompt 145 * @param bool $cache cache given value for next time? 146 * @return string 147 */ 148 protected function readLine($prompt, $cache = false) 149 { 150 $value = ''; 151 $default = ''; 152 $cachename = getCacheName($prompt, '.readline'); 153 if ($cache && file_exists($cachename)) { 154 $default = file_get_contents($cachename); 155 } 156 157 while ($value === '') { 158 echo $prompt; 159 if ($default) echo ' [' . $default . ']'; 160 echo ': '; 161 162 $fh = fopen('php://stdin', 'r'); 163 $value = trim(fgets($fh)); 164 fclose($fh); 165 166 if ($value === '') $value = $default; 167 } 168 169 if ($cache) { 170 file_put_contents($cachename, $value); 171 } 172 173 return $value; 174 } 175 176 /** 177 * Download a skeleton file and do the replacements 178 * 179 * @param string $skel Skeleton relative to the skel dir in the repo 180 * @param string $target Target file relative to the main directory 181 * @param array $replacements 182 */ 183 protected function loadSkeleton($skel, $target, $replacements) 184 { 185 if (file_exists($target)) { 186 $this->error($target . ' already exists'); 187 return; 188 } 189 190 $base = 'https://raw.githubusercontent.com/dokufreaks/dokuwiki-plugin-wizard/master/skel/'; 191 $http = new \dokuwiki\HTTP\DokuHTTPClient(); 192 $content = $http->get($base . $skel); 193 194 $content = str_replace( 195 array_keys($replacements), 196 array_values($replacements), 197 $content 198 ); 199 200 io_makeFileDir($target); 201 file_put_contents($target, $content); 202 $this->success('Added ' . $target); 203 } 204 205 /** 206 * Prepare the string replacements 207 * 208 * @param array $replacements override defaults 209 * @return array 210 */ 211 protected function prepareReplacements($replacements = []) 212 { 213 // defaults 214 $data = [ 215 '@@AUTHOR_NAME@@' => '', 216 '@@AUTHOR_MAIL@@' => '', 217 '@@PLUGIN_NAME@@' => '', 218 '@@PLUGIN_DESC@@' => '', 219 '@@PLUGIN_URL@@' => '', 220 '@@PLUGIN_TYPE@@' => '', 221 '@@INSTALL_DIR@@' => 'plugins', 222 '@@DATE@@' => date('Y-m-d'), 223 ]; 224 225 // load from existing plugin.info 226 $dir = fullpath(getcwd()); 227 [$name, $type] = $this->getTypedNameFromDir($dir); 228 if (file_exists("$type.info.txt")) { 229 $info = confToHash("$type.info.txt"); 230 $data['@@AUTHOR_NAME@@'] = $info['author']; 231 $data['@@AUTHOR_MAIL@@'] = $info['email']; 232 $data['@@PLUGIN_DESC@@'] = $info['desc']; 233 $data['@@PLUGIN_URL@@'] = $info['url']; 234 } 235 $data['@@PLUGIN_NAME@@'] = $name; 236 $data['@@PLUGIN_TYPE@@'] = $type; 237 238 if ($type == 'template') { 239 $data['@@INSTALL_DIR@@'] = 'tpl'; 240 } 241 242 // merge given overrides 243 $data = array_merge($data, $replacements); 244 245 // set inherited defaults 246 if (empty($data['@@PLUGIN_URL@@'])) { 247 $data['@@PLUGIN_URL@@'] = 248 'https://www.dokuwiki.org/' . 249 $data['@@PLUGIN_TYPE@@'] . ':' . 250 $data['@@PLUGIN_NAME@@']; 251 } 252 253 return $data; 254 } 255 256 /** 257 * Replacements needed for action components. 258 * 259 * Not cool but that' what we need currently 260 * 261 * @return string[] 262 */ 263 protected function actionReplacements() 264 { 265 $fn = 'handleEventName'; 266 $register = ' $controller->register_hook(\'EVENT_NAME\', \'AFTER|BEFORE\', $this, \'' . $fn . '\');'; 267 $handler = ' public function ' . $fn . '(Doku_Event $event, $param)' . "\n" 268 . " {\n" 269 . " }\n"; 270 271 return [ 272 '@@REGISTER@@' => $register . "\n ", 273 '@@HANDLERS@@' => $handler, 274 ]; 275 } 276 277 /** 278 * Delete the given file if it exists 279 * 280 * @param string $file 281 */ 282 protected function deleteFile($file) 283 { 284 if (!file_exists($file)) return; 285 if (@unlink($file)) { 286 $this->success('Delete ' . $file); 287 } 288 } 289 290 /** 291 * Run git with the given arguments and return the output 292 * 293 * @throws CliException when the command can't be run 294 * @param string ...$args 295 * @return string[] 296 */ 297 protected function git(...$args) 298 { 299 $args = array_map('escapeshellarg', $args); 300 $cmd = 'git ' . join(' ', $args); 301 $output = []; 302 $result = 0; 303 304 $this->info($cmd); 305 $last = exec($cmd, $output, $result); 306 if ($last === false || $result !== 0) { 307 throw new CliException('Running git failed'); 308 } 309 310 return $output; 311 } 312 313 // region Commands 314 315 /** 316 * Intialize the current directory as a plugin or template 317 * 318 * @return int 319 */ 320 protected function cmdInit() 321 { 322 $dir = fullpath(getcwd()); 323 if ((new FilesystemIterator($dir))->valid()) { 324 throw new CliException('Current directory needs to be empty'); 325 } 326 327 [$name, $type] = $this->getTypedNameFromDir($dir); 328 $user = $this->readLine('Your Name', true); 329 $mail = $this->readLine('Your E-Mail', true); 330 $desc = $this->readLine('Short description'); 331 332 $replacements = [ 333 '@@AUTHOR_NAME@@' => $user, 334 '@@AUTHOR_MAIL@@' => $mail, 335 '@@PLUGIN_NAME@@' => $name, 336 '@@PLUGIN_DESC@@' => $desc, 337 '@@PLUGIN_TYPE@@' => $type, 338 ]; 339 $replacements = $this->prepareReplacements($replacements); 340 341 $this->loadSkeleton('info.skel', $type . '.info.txt', $replacements); 342 $this->loadSkeleton('README.skel', 'README', $replacements); // fixme needs to be type specific 343 $this->loadSkeleton('LICENSE.skel', 'LICENSE', $replacements); 344 345 try { 346 $this->git('init'); 347 } catch (CliException $e) { 348 $this->error($e->getMessage()); 349 } 350 351 return 0; 352 } 353 354 /** 355 * Add test framework 356 * 357 * @param string $test Name of the Test to add 358 * @return int 359 */ 360 protected function cmdAddTest($test = '') 361 { 362 $test = ucfirst(strtolower($test)); 363 364 $replacements = $this->prepareReplacements(['@@TEST@@' => $test]); 365 $this->loadSkeleton('.github/workflows/phpTestLinux.skel', '.github/workflows/phpTestLinux.yml', $replacements); 366 if ($test) { 367 $this->loadSkeleton('_test/StandardTest.skel', '_test/' . $test . 'Test.php', $replacements); 368 } else { 369 $this->loadSkeleton('_test/GeneralTest.skel', '_test/GeneralTest.php', $replacements); 370 } 371 372 return 0; 373 } 374 375 /** 376 * Add configuration 377 * 378 * @return int 379 */ 380 protected function cmdAddConf() 381 { 382 $replacements = $this->prepareReplacements(); 383 $this->loadSkeleton('conf/default.skel', 'conf/default.php', $replacements); 384 $this->loadSkeleton('conf/metadata.skel', 'conf/metadata.php', $replacements); 385 if (is_dir('lang')) { 386 $this->loadSkeleton('lang/settings.skel', 'lang/en/settings.php', $replacements); 387 } 388 389 return 0; 390 } 391 392 /** 393 * Add language 394 * 395 * @return int 396 */ 397 protected function cmdAddLang() 398 { 399 $replacements = $this->prepareReplacements(); 400 $this->loadSkeleton('lang/lang.skel', 'lang/en/lang.php', $replacements); 401 if (is_dir('conf')) { 402 $this->loadSkeleton('lang/settings.skel', 'lang/en/settings.php', $replacements); 403 } 404 405 return 0; 406 } 407 408 /** 409 * Add another component to the plugin 410 * 411 * @param string $type 412 * @param string $component 413 */ 414 protected function cmdAddComponent($type, $component = '') 415 { 416 $dir = fullpath(getcwd()); 417 list($plugin, $extension) = $this->getTypedNameFromDir($dir); 418 if ($extension != 'plugin') throw new CliException('Components can only be added to plugins'); 419 if (!in_array($type, PluginController::PLUGIN_TYPES)) { 420 throw new CliException('Invalid type ' . $type); 421 } 422 423 if ($component) { 424 $path = $type . '/' . $component . '.php'; 425 $class = $type . '_plugin_' . $plugin . '_' . $component; 426 $self = $plugin . '_' . $component; 427 } else { 428 $path = $type . '.php'; 429 $class = $type . '_plugin_' . $plugin; 430 $self = $plugin; 431 } 432 433 $replacements = $this->actionReplacements(); 434 $replacements['@@PLUGIN_COMPONENT_NAME@@'] = $class; 435 $replacements['@@SYNTAX_COMPONENT_NAME@@'] = $self; 436 $replacements = $this->prepareReplacements($replacements); 437 $this->loadSkeleton($type . '.skel', $path, $replacements); 438 439 return 0; 440 } 441 442 /** 443 * Generate a list of deleted files from git 444 * 445 * @link https://stackoverflow.com/a/6018049/172068 446 */ 447 protected function cmdDeletedFiles() 448 { 449 if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git'); 450 451 $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D'); 452 $output = array_map('trim', $output); 453 $output = array_filter($output); 454 $output = array_unique($output); 455 $output = array_filter($output, function ($item) { 456 return !file_exists($item); 457 }); 458 sort($output); 459 460 if (!count($output)) { 461 $this->info('No deleted files found'); 462 return 0; 463 } 464 465 $content = "# This is a list of files that were present in previous releases\n" . 466 "# but were removed later. They should not exist in your installation.\n" . 467 join("\n", $output) . "\n"; 468 469 file_put_contents('deleted.files', $content); 470 $this->success('written deleted.files'); 471 return 0; 472 } 473 474 /** 475 * Remove files that shouldn't be here anymore 476 */ 477 protected function cmdRmObsolete() 478 { 479 $this->deleteFile('_test/general.test.php'); 480 $this->deleteFile('.travis.yml'); 481 482 return 0; 483 } 484 485 /** 486 * Download a remote icon 487 * 488 * @param string $ident 489 * @param string $save 490 * @return int 491 * @throws Exception 492 */ 493 protected function cmdDownloadSVG($ident, $save = '') 494 { 495 $svg = new SVGIcon($this); 496 return (int)$svg->downloadRemoteIcon($ident, $save); 497 } 498 499 /** 500 * @param string $file 501 * @return int 502 * @throws Exception 503 */ 504 protected function cmdCleanSVG($file) 505 { 506 $svg = new SVGIcon($this); 507 return (int)$svg->cleanSVGFile($file); 508 } 509 510 //endregion 511} 512