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