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