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