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