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