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