xref: /plugin/dev/cli.php (revision 53bec4caf87f4baf3a232113da52346bcbea6f83)
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