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