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