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\Skeletor; 8use dokuwiki\plugin\dev\SVGIcon; 9use splitbrain\phpcli\Exception as CliException; 10use splitbrain\phpcli\Options; 11 12/** 13 * @license GPL2 14 * @author Andreas Gohr <andi@splitbrain.org> 15 */ 16class cli_plugin_dev extends CLIPlugin 17{ 18 /** 19 * Register options and arguments on the given $options object 20 * 21 * @param Options $options 22 * @return void 23 */ 24 protected function setup(Options $options) 25 { 26 $options->useCompactHelp(); 27 $options->setHelp( 28 "CLI to help with DokuWiki plugin and template development.\n\n" . 29 "Run this script from within the extension's directory." 30 ); 31 32 $options->registerCommand('init', 'Initialize a new plugin or template in the current directory.'); 33 $options->registerCommand('addTest', 'Add the testing framework files and a test. (_test/)'); 34 $options->registerArgument('test', 'Optional name of the new test. Defaults to the general test.', false, 35 'addTest'); 36 $options->registerCommand('addConf', 'Add the configuration files. (conf/)'); 37 $options->registerCommand('addLang', 'Add the language files. (lang/)'); 38 39 $types = PluginController::PLUGIN_TYPES; 40 array_walk( 41 $types, 42 function (&$item) { 43 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 44 } 45 ); 46 47 $options->registerCommand('addComponent', 'Add a new plugin component.'); 48 $options->registerArgument('type', 'Type of the component. Needs to be one of ' . join(', ', $types), true, 49 'addComponent'); 50 $options->registerArgument('name', 'Optional name of the component. Defaults to a base component.', false, 51 'addComponent'); 52 53 $options->registerCommand('deletedFiles', 'Create the list of deleted files based on the git history.'); 54 $options->registerCommand('rmObsolete', 'Delete obsolete files.'); 55 56 $prefixes = array_keys(SVGIcon::SOURCES); 57 array_walk( 58 $prefixes, 59 function (&$item) { 60 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 61 } 62 ); 63 64 $options->registerCommand('downloadSvg', 'Download an SVG file from a known icon repository.'); 65 $options->registerArgument('prefix:name', 66 'Colon-prefixed name of the icon. Available prefixes: ' . join(', ', $prefixes), true, 'downloadSvg'); 67 $options->registerArgument('output', 'File to save, defaults to <name>.svg in current dir', false, 68 'downloadSvg'); 69 $options->registerOption('keep-ns', 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 'k', 70 false, 'downloadSvg'); 71 72 $options->registerCommand('cleanSvg', 'Clean a existing SVG files to reduce their file size.'); 73 $options->registerArgument('files...', 'The files to clean (will be overwritten)', true, 'cleanSvg'); 74 $options->registerOption('keep-ns', 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 'k', 75 false, 'cleanSvg'); 76 77 $options->registerCommand('cleanLang', 78 'Clean language files from unused language strings. Detecting which strings are truly in use may ' . 79 'not always correctly work. Use with caution.'); 80 } 81 82 /** @inheritDoc */ 83 protected function main(Options $options) 84 { 85 $args = $options->getArgs(); 86 87 switch ($options->getCmd()) { 88 case 'init': 89 return $this->cmdInit(); 90 case 'addTest': 91 $test = array_shift($args); 92 return $this->cmdAddTest($test); 93 case 'addConf': 94 return $this->cmdAddConf(); 95 case 'addLang': 96 return $this->cmdAddLang(); 97 case 'addComponent': 98 $type = array_shift($args); 99 $component = array_shift($args); 100 return $this->cmdAddComponent($type, $component); 101 case 'deletedFiles': 102 return $this->cmdDeletedFiles(); 103 case 'rmObsolete': 104 return $this->cmdRmObsolete(); 105 case 'downloadSvg': 106 $ident = array_shift($args); 107 $save = array_shift($args); 108 $keep = $options->getOpt('keep-ns'); 109 return $this->cmdDownloadSVG($ident, $save, $keep); 110 case 'cleanSvg': 111 $keep = $options->getOpt('keep-ns'); 112 return $this->cmdCleanSVG($args, $keep); 113 case 'cleanLang': 114 return $this->cmdCleanLang(); 115 default: 116 $this->error('Unknown command'); 117 echo $options->help(); 118 return 0; 119 } 120 } 121 122 /** 123 * Get the extension name from the current working directory 124 * 125 * @throws CliException if something's wrong 126 * @param string $dir 127 * @return string[] name, type 128 */ 129 protected function getTypedNameFromDir($dir) 130 { 131 $pdir = fullpath(DOKU_PLUGIN); 132 $tdir = fullpath(tpl_incdir() . '../'); 133 134 if (strpos($dir, $pdir) === 0) { 135 $ldir = substr($dir, strlen($pdir)); 136 $type = 'plugin'; 137 } elseif (strpos($dir, $tdir) === 0) { 138 $ldir = substr($dir, strlen($tdir)); 139 $type = 'template'; 140 } else { 141 throw new CliException('Current directory needs to be in plugin or template directory'); 142 } 143 144 $ldir = trim($ldir, '/'); 145 146 if (strpos($ldir, '/') !== false) { 147 throw new CliException('Current directory has to be main extension directory'); 148 } 149 150 return [$ldir, $type]; 151 } 152 153 /** 154 * Interactively ask for a value from the user 155 * 156 * @param string $prompt 157 * @param bool $cache cache given value for next time? 158 * @return string 159 */ 160 protected function readLine($prompt, $cache = false) 161 { 162 $value = ''; 163 $default = ''; 164 $cachename = getCacheName($prompt, '.readline'); 165 if ($cache && file_exists($cachename)) { 166 $default = file_get_contents($cachename); 167 } 168 169 while ($value === '') { 170 echo $prompt; 171 if ($default) echo ' [' . $default . ']'; 172 echo ': '; 173 174 $fh = fopen('php://stdin', 'r'); 175 $value = trim(fgets($fh)); 176 fclose($fh); 177 178 if ($value === '') $value = $default; 179 } 180 181 if ($cache) { 182 file_put_contents($cachename, $value); 183 } 184 185 return $value; 186 } 187 188 /** 189 * Create the given files with their given content 190 * 191 * Ignores all files that already exist 192 * 193 * @param array $files A File array as created by Skeletor::getFiles() 194 */ 195 protected function createFiles($files) 196 { 197 foreach ($files as $path => $content) { 198 if (file_exists($path)) { 199 $this->error($path . ' already exists'); 200 continue; 201 } 202 203 io_makeFileDir($path); 204 file_put_contents($path, $content); 205 $this->success($path . ' created'); 206 } 207 } 208 209 /** 210 * Delete the given file if it exists 211 * 212 * @param string $file 213 */ 214 protected function deleteFile($file) 215 { 216 if (!file_exists($file)) return; 217 if (@unlink($file)) { 218 $this->success('Delete ' . $file); 219 } 220 } 221 222 /** 223 * Run git with the given arguments and return the output 224 * 225 * @throws CliException when the command can't be run 226 * @param string ...$args 227 * @return string[] 228 */ 229 protected function git(...$args) 230 { 231 $args = array_map('escapeshellarg', $args); 232 $cmd = 'git ' . join(' ', $args); 233 $output = []; 234 $result = 0; 235 236 $this->info($cmd); 237 $last = exec($cmd, $output, $result); 238 if ($last === false || $result !== 0) { 239 throw new CliException('Running git failed'); 240 } 241 242 return $output; 243 } 244 245 // region Commands 246 247 /** 248 * Intialize the current directory as a plugin or template 249 * 250 * @return int 251 */ 252 protected function cmdInit() 253 { 254 $dir = fullpath(getcwd()); 255 if ((new FilesystemIterator($dir))->valid()) { 256 // existing directory, initialize from info file 257 $skeletor = Skeletor::fromDir($dir); 258 } else { 259 // new directory, ask for info 260 [$base, $type] = $this->getTypedNameFromDir($dir); 261 $user = $this->readLine('Your Name', true); 262 $mail = $this->readLine('Your E-Mail', true); 263 $desc = $this->readLine('Short description'); 264 $skeletor = new Skeletor($type, $base, $desc, $user, $mail); 265 } 266 $skeletor->addBasics(); 267 $this->createFiles($skeletor->getFiles()); 268 269 if (!is_dir("$dir/.git")) { 270 try { 271 $this->git('init'); 272 } catch (CliException $e) { 273 $this->error($e->getMessage()); 274 } 275 } 276 277 return 0; 278 } 279 280 /** 281 * Add test framework 282 * 283 * @param string $test Name of the Test to add 284 * @return int 285 */ 286 protected function cmdAddTest($test = '') 287 { 288 $skeletor = Skeletor::fromDir(getcwd()); 289 $skeletor->addTest($test); 290 $this->createFiles($skeletor->getFiles()); 291 return 0; 292 } 293 294 /** 295 * Add configuration 296 * 297 * @return int 298 */ 299 protected function cmdAddConf() 300 { 301 $skeletor = Skeletor::fromDir(getcwd()); 302 $skeletor->addConf(is_dir('lang')); 303 $this->createFiles($skeletor->getFiles()); 304 return 0; 305 } 306 307 /** 308 * Add language 309 * 310 * @return int 311 */ 312 protected function cmdAddLang() 313 { 314 $skeletor = Skeletor::fromDir(getcwd()); 315 $skeletor->addLang(is_dir('conf')); 316 $this->createFiles($skeletor->getFiles()); 317 return 0; 318 } 319 320 /** 321 * Add another component to the plugin 322 * 323 * @param string $type 324 * @param string $component 325 */ 326 protected function cmdAddComponent($type, $component = '') 327 { 328 $skeletor = Skeletor::fromDir(getcwd()); 329 $skeletor->addComponent($type, $component); 330 $this->createFiles($skeletor->getFiles()); 331 return 0; 332 } 333 334 /** 335 * Generate a list of deleted files from git 336 * 337 * @link https://stackoverflow.com/a/6018049/172068 338 */ 339 protected function cmdDeletedFiles() 340 { 341 if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git'); 342 343 $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D'); 344 $output = array_map('trim', $output); 345 $output = array_filter($output); 346 $output = array_unique($output); 347 $output = array_filter($output, function ($item) { 348 return !file_exists($item); 349 }); 350 sort($output); 351 352 if (!count($output)) { 353 $this->info('No deleted files found'); 354 return 0; 355 } 356 357 $content = "# This is a list of files that were present in previous releases\n" . 358 "# but were removed later. They should not exist in your installation.\n" . 359 join("\n", $output) . "\n"; 360 361 file_put_contents('deleted.files', $content); 362 $this->success('written deleted.files'); 363 return 0; 364 } 365 366 /** 367 * Remove files that shouldn't be here anymore 368 */ 369 protected function cmdRmObsolete() 370 { 371 $this->deleteFile('_test/general.test.php'); 372 $this->deleteFile('.travis.yml'); 373 $this->deleteFile('.github/workflows/phpTestLinux.yml'); 374 375 return 0; 376 } 377 378 /** 379 * Download a remote icon 380 * 381 * @param string $ident 382 * @param string $save 383 * @param bool $keep 384 * @return int 385 * @throws Exception 386 */ 387 protected function cmdDownloadSVG($ident, $save = '', $keep = false) 388 { 389 $svg = new SVGIcon($this); 390 $svg->keepNamespace($keep); 391 return (int)$svg->downloadRemoteIcon($ident, $save); 392 } 393 394 /** 395 * @param string[] $files 396 * @param bool $keep 397 * @return int 398 * @throws Exception 399 */ 400 protected function cmdCleanSVG($files, $keep = false) 401 { 402 $svg = new SVGIcon($this); 403 $svg->keepNamespace($keep); 404 405 $ok = true; 406 foreach ($files as $file) { 407 $ok = $ok && $svg->cleanSVGFile($file); 408 } 409 return (int)$ok; 410 } 411 412 /** 413 * @return int 414 */ 415 protected function cmdCleanLang() 416 { 417 $lp = new LangProcessor($this); 418 419 $files = glob('./lang/*/lang.php'); 420 foreach ($files as $file) { 421 $lp->processLangFile($file); 422 } 423 424 $files = glob('./lang/*/settings.php'); 425 foreach ($files as $file) { 426 $lp->processSettingsFile($file); 427 } 428 429 return 0; 430 } 431 432 //endregion 433} 434