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