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