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