Passed
Pull Request — master (#54)
by Stefano
02:18
created

GettextCommand   F

Complexity

Total Complexity 65

Size/Duplication

Total Lines 557
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 251
dl 0
loc 557
rs 3.2
c 0
b 0
f 0
wmc 65

18 Methods

Rating   Name   Duplication   Size   Complexity  
A strposX() 0 7 2
A header() 0 31 2
B parseContentSecondArg() 0 31 7
A ttagExtract() 0 33 4
A fixString() 0 8 1
A writePoFiles() 0 24 5
A getPoResult() 0 3 1
A parseContent() 0 27 5
A execute() 0 29 3
A parseDir() 0 11 5
A setupPaths() 0 26 4
B parseFile() 0 38 7
A parseContentThirdArg() 0 31 5
A buildOptionParser() 0 19 1
B analyzePoFile() 0 22 7
A writeMasterPot() 0 14 4
A getLocalePath() 0 3 1
A getTemplatePaths() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like GettextCommand often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GettextCommand, and based on these observations, apply Extract Interface, too.

1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2022 Atlas Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
16
namespace BEdita\I18n\Command;
17
18
use Cake\Command\Command;
19
use Cake\Console\Arguments;
20
use Cake\Console\ConsoleIo;
21
use Cake\Console\ConsoleOptionParser;
22
use Cake\Core\Configure;
23
use Cake\Filesystem\File;
24
use Cake\Filesystem\Folder;
25
use Cake\Utility\Hash;
26
27
/**
28
 * Gettext command.
29
 */
30
class GettextCommand extends Command
31
{
32
    /**
33
     * The Po results
34
     *
35
     * @var array
36
     */
37
    protected $poResult = [];
38
39
    /**
40
     * The template paths
41
     *
42
     * @var array
43
     */
44
    protected $templatePaths = [];
45
46
    /**
47
     * The locale path
48
     *
49
     * @var string
50
     */
51
    protected $localePath = null;
52
53
    /**
54
     * The name of default domain if not specified. Used for pot and po file names.
55
     *
56
     * @var string
57
     */
58
    protected $defaultDomain = 'default';
59
60
    /**
61
     * @inheritDoc
62
     */
63
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
64
    {
65
        return parent::buildOptionParser($parser)
66
            ->setDescription([
67
                'Create or update i18n po/pot files',
68
                '',
69
                '`bin/cake gettext`: update files for current app',
70
                '`bin/cake gettext -app <app path>`: update files for the app',
71
                '`bin/cake gettext -plugin <plugin name>`: update files for the plugin',
72
            ])
73
            ->addOption('app', [
74
                'help' => 'The app path, for i18n update.',
75
                'short' => 'a',
76
                'required' => false,
77
            ])
78
            ->addOption('plugin', [
79
                'help' => 'The plugin name, for i18n update.',
80
                'short' => 'p',
81
                'required' => false,
82
            ]);
83
    }
84
85
    /**
86
     * Get po result.
87
     *
88
     * @return array
89
     */
90
    public function getPoResult(): array
91
    {
92
        return $this->poResult;
93
    }
94
95
    /**
96
     * Get templatePaths.
97
     *
98
     * @return array
99
     */
100
    public function getTemplatePaths(): array
101
    {
102
        return $this->templatePaths;
103
    }
104
105
    /**
106
     * Get localePath
107
     *
108
     * @return string
109
     */
110
    public function getLocalePath(): string
111
    {
112
        return $this->localePath;
113
    }
114
115
    /**
116
     * Update gettext po files.
117
     *
118
     * @param \Cake\Console\Arguments $args The command arguments.
119
     * @param \Cake\Console\ConsoleIo $io The console io
120
     * @return null|void|int The exit code or null for success
121
     */
122
    public function execute(Arguments $args, ConsoleIo $io)
123
    {
124
        $resCmd = [];
125
        exec('which msgmerge 2>&1', $resCmd);
126
        if (empty($resCmd[0])) {
127
            $io->abort('ERROR: msgmerge not available. Please install gettext utilities.');
128
        }
129
130
        $io->out('Updating .pot and .po files...');
131
132
        $this->setupPaths($args);
133
        foreach ($this->templatePaths as $path) {
134
            $io->out(sprintf('Search in: %s', $path));
135
            $this->parseDir($path);
136
        }
137
138
        $io->out('Creating master .pot file');
139
        $this->writeMasterPot($io);
140
        $this->ttagExtract($args, $io);
141
142
        $io->hr();
143
        $io->out('Merging master .pot with current .po files');
144
        $io->hr();
145
146
        $this->writePoFiles($io);
147
148
        $io->out('Done');
149
150
        return null;
151
    }
152
153
    /**
154
     * Setup template paths and locale path
155
     *
156
     * @param \Cake\Console\Arguments $args The command arguments.
157
     * @return void
158
     */
159
    private function setupPaths(Arguments $args): void
160
    {
161
        $appTemplates = (array)Configure::read('App.paths.templates');
162
        $plugin = $args->getOption('plugin');
163
        if ($plugin) {
164
            $f = new Folder(sprintf('%s%s', (string)Configure::read('App.paths.plugins.0'), $plugin));
0 ignored issues
show
Bug introduced by
It seems like $plugin can also be of type true; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

164
            $f = new Folder(sprintf('%s%s', (string)Configure::read('App.paths.plugins.0'), /** @scrutinizer ignore-type */ $plugin));
Loading history...
Deprecated Code introduced by
The class Cake\Filesystem\Folder has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

164
            $f = /** @scrutinizer ignore-deprecated */ new Folder(sprintf('%s%s', (string)Configure::read('App.paths.plugins.0'), $plugin));
Loading history...
165
            $basePath = $f->path;
166
            $this->defaultDomain = $plugin;
0 ignored issues
show
Documentation Bug introduced by
It seems like $plugin can also be of type true. However, the property $defaultDomain is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
167
            $this->templatePaths = [$basePath . '/src', $basePath . '/config'];
168
            $appTemplatePath = (string)Hash::get($appTemplates, '1');
169
            if (strpos($appTemplatePath, $basePath . '/src') === false) {
170
                $this->templatePaths[] = $appTemplatePath;
171
            }
172
            $this->localePath = (string)Configure::read('App.paths.locales.1');
173
174
            return;
175
        }
176
        $app = $args->getOption('app') ?? getcwd();
177
        $f = new Folder($app);
0 ignored issues
show
Bug introduced by
It seems like $app can also be of type boolean; however, parameter $path of Cake\Filesystem\Folder::__construct() does only seem to accept null|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

177
        $f = new Folder(/** @scrutinizer ignore-type */ $app);
Loading history...
Deprecated Code introduced by
The class Cake\Filesystem\Folder has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

177
        $f = /** @scrutinizer ignore-deprecated */ new Folder($app);
Loading history...
178
        $basePath = $f->path;
179
        $this->templatePaths = [$basePath . '/src', $basePath . '/config'];
180
        $appTemplatePath = (string)Hash::get($appTemplates, '0');
181
        if (strpos($appTemplatePath, $basePath . '/src') === false) {
182
            $this->templatePaths[] = $appTemplatePath;
183
        }
184
        $this->localePath = (string)Configure::read('App.paths.locales.0');
185
    }
186
187
    /**
188
     * Write `master.pot` file
189
     *
190
     * @param \Cake\Console\ConsoleIo $io The console io
191
     * @return void
192
     */
193
    private function writeMasterPot(ConsoleIo $io): void
194
    {
195
        foreach ($this->poResult as $domain => $poResult) {
196
            $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
197
            $io->out(sprintf('Writing new .pot file: %s', $potFilename));
198
            $pot = new File($potFilename, true);
0 ignored issues
show
Deprecated Code introduced by
The class Cake\Filesystem\File has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

198
            $pot = /** @scrutinizer ignore-deprecated */ new File($potFilename, true);
Loading history...
199
            $pot->write($this->header('pot'));
200
            sort($poResult);
201
            foreach ($poResult as $res) {
202
                if (!empty($res)) {
203
                    $pot->write(sprintf('%smsgid "%s"%smsgstr ""%s', "\n", $res, "\n", "\n"));
204
                }
205
            }
206
            $pot->close();
207
        }
208
    }
209
210
    /**
211
     * Write `.po` files
212
     *
213
     * @param \Cake\Console\ConsoleIo $io The console io
214
     * @return void
215
     */
216
    private function writePoFiles(ConsoleIo $io): void
217
    {
218
        $header = $this->header('po');
219
        $locales = array_keys((array)Configure::read('I18n.locales', []));
220
        foreach ($locales as $loc) {
221
            $potDir = $this->localePath . DS . $loc;
222
            if (!file_exists($potDir)) {
223
                mkdir($potDir);
224
            }
225
            $io->out(sprintf('Language: %s', $loc));
226
227
            foreach (array_keys($this->poResult) as $domain) {
228
                $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
229
                $poFile = sprintf('%s/%s.po', $potDir, $domain);
230
                if (!file_exists($poFile)) {
231
                    $newPoFile = new File($poFile, true);
0 ignored issues
show
Deprecated Code introduced by
The class Cake\Filesystem\File has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

231
                    $newPoFile = /** @scrutinizer ignore-deprecated */ new File($poFile, true);
Loading history...
232
                    $newPoFile->write($header);
233
                    $newPoFile->close();
234
                }
235
                $io->out(sprintf('Merging %s', $poFile));
236
                $mergeCmd = sprintf('msgmerge --backup=off -N -U %s %s', $poFile, $potFilename);
237
                exec($mergeCmd);
238
                $this->analyzePoFile($poFile, $io);
239
                $io->hr();
240
            }
241
        }
242
    }
243
244
    /**
245
     * Header lines for po/pot file
246
     *
247
     * @param string $type The file type (can be 'po', 'pot')
248
     * @return string
249
     * @codeCoverageIgnore
250
     */
251
    private function header(string $type = 'po'): string
252
    {
253
        $result = sprintf('msgid ""%smsgstr ""%s', "\n", "\n");
254
        $contents = [
255
            'po' => [
256
                'Project-Id-Version' => 'BEdita 4',
257
                'POT-Creation-Date' => date('Y-m-d H:i:s'),
258
                'PO-Revision-Date' => '',
259
                'Last-Translator' => '',
260
                'Language-Team' => 'BEdita I18N & I10N Team',
261
                'Language' => '',
262
                'MIME-Version' => '1.0',
263
                'Content-Transfer-Encoding' => '8bit',
264
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
265
                'Content-Type' => 'text/plain; charset=utf-8',
266
            ],
267
            'pot' => [
268
                'Project-Id-Version' => 'BEdita 4',
269
                'POT-Creation-Date' => date('Y-m-d H:i:s'),
270
                'MIME-Version' => '1.0',
271
                'Content-Transfer-Encoding' => '8bit',
272
                'Language-Team' => 'BEdita I18N & I10N Team',
273
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
274
                'Content-Type' => 'text/plain; charset=utf-8',
275
            ],
276
        ];
277
        foreach ($contents[$type] as $k => $v) {
278
            $result .= sprintf('"%s: %s \n"', $k, $v) . "\n";
279
        }
280
281
        return $result;
282
    }
283
284
    /**
285
     * Analyze po file and translate it
286
     *
287
     * @param string $filename The po file name
288
     * @param \Cake\Console\ConsoleIo $io The console io
289
     * @return void
290
     */
291
    private function analyzePoFile($filename, ConsoleIo $io): void
292
    {
293
        $lines = file($filename);
294
        $numItems = $numNotTranslated = 0;
295
        foreach ($lines as $k => $l) {
296
            if (strpos($l, 'msgid "') === 0) {
297
                $numItems++;
298
            }
299
            if (strpos($l, 'msgstr ""') === 0) {
300
                if (!isset($lines[$k + 1])) {
301
                    $numNotTranslated++;
302
                } elseif (strpos($lines[$k + 1], '"') !== 0) {
303
                    $numNotTranslated++;
304
                }
305
            }
306
        }
307
        $translated = $numItems - $numNotTranslated;
308
        $percent = 0;
309
        if ($numItems > 0) {
310
            $percent = number_format($translated * 100. / $numItems, 1);
311
        }
312
        $io->out(sprintf('Translated %d of %d items - %s %%', $translated, $numItems, $percent));
313
    }
314
315
    /**
316
     * "fix" string - strip slashes, escape and convert new lines to \n
317
     *
318
     * @param string $str The string
319
     * @return string The new string
320
     */
321
    private function fixString($str): string
322
    {
323
        $str = stripslashes($str);
324
        $str = str_replace('"', '\"', $str);
325
        $str = str_replace("\n", '\n', $str);
326
        $str = str_replace('|||||', "'", $str); // special sequence used in parseContent to temporarily replace "\'"
327
328
        return $str;
329
    }
330
331
    /**
332
     * Parse file and rips gettext strings
333
     *
334
     * @param string $file The file name
335
     * @param string $extension The file extension
336
     * @return void
337
     */
338
    private function parseFile($file, $extension)
339
    {
340
        if (!in_array($extension, ['php', 'twig'])) {
341
            return;
342
        }
343
        $content = file_get_contents($file);
344
        if (empty($content)) {
345
            return;
346
        }
347
348
        $functions = [
349
            '__' => 0, // __( string $singular , ... $args )
350
            '__n' => 0, // __n( string $singular , string $plural , integer $count , ... $args )
351
            '__d' => 1, // __d( string $domain , string $msg , ... $args )
352
            '__dn' => 1, // __dn( string $domain , string $singular , string $plural , integer $count , ... $args )
353
            '__x' => 1, // __x( string $context , string $singular , ... $args )
354
            '__xn' => 1, // __xn( string $context , string $singular , string $plural , integer $count , ... $args )
355
            '__dx' => 2, // __dx( string $domain , string $context , string $msg , ... $args )
356
            '__dxn' => 2, // __dxn( string $domain , string $context , string $singular , string $plural , integer $count , ... $args )
357
        ];
358
359
        // temporarily replace "\'" with "|||||", fixString will replace "|||||" with "\'"
360
        // this fixes wrongly matched data in the following regexp
361
        $content = str_replace("\'", '|||||', $content);
362
363
        $options = [
364
            'open_parenthesis' => preg_quote('('),
365
            'quote' => preg_quote("'"),
366
            'double_quote' => preg_quote('"'),
367
        ];
368
369
        foreach ($functions as $fname => $singularPosition) {
370
            if ($singularPosition === 0) {
371
                $this->parseContent($fname, $content, $options);
372
            } elseif ($singularPosition === 1) {
373
                $this->parseContentSecondArg($fname, $content, $options);
374
            } elseif ($singularPosition === 2) {
375
                $this->parseContentThirdArg($fname, $content, $options);
376
            }
377
        }
378
    }
379
380
    /**
381
     * Parse file content and put i18n data in poResult array
382
     *
383
     * @param string $start The starting string to search for, the name of the translation method
384
     * @param string $content The file content
385
     * @param array $options The options
386
     * @return void
387
     */
388
    private function parseContent($start, $content, $options): void
389
    {
390
        // phpcs:disable
391
        $rgxp = '/' .
392
            "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . "([^{$options['double_quote']}]*)" . "{$options['double_quote']}" .
393
            '|' .
394
            "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . "([^{$options['quote']}]*)" . "{$options['quote']}" .
395
            '/';
396
        // phpcs:enable
397
        $matches = [];
398
        preg_match_all($rgxp, $content, $matches);
399
400
        $domain = $this->defaultDomain;
401
402
        $limit = count($matches[0]);
403
        for ($i = 0; $i < $limit; $i++) {
404
            $item = $this->fixString($matches[1][$i]);
405
            if (empty($item)) {
406
                $item = $this->fixString($matches[2][$i]);
407
            }
408
409
            if (!array_key_exists($domain, $this->poResult)) {
410
                $this->poResult[$domain] = [];
411
            }
412
413
            if (!in_array($item, $this->poResult[$domain])) {
414
                $this->poResult[$domain][] = $item;
415
            }
416
        }
417
    }
418
419
    /**
420
     * Parse file content and put i18n data in poResult array
421
     *
422
     * @param string $start The starting string to search for, the name of the translation method
423
     * @param string $content The file content
424
     * @param array $options The options
425
     * @return void
426
     */
427
    private function parseContentSecondArg($start, $content, $options): void
428
    {
429
        // phpcs:disable
430
        $rgxp =
431
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . '([^{)}]*)' . "{$options['double_quote']}" .
432
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . '([^{)}]*)' . "{$options['quote']}" .
433
            '/';
434
        // phpcs:enable
435
        $matches = [];
436
        preg_match_all($rgxp, $content, $matches);
437
438
        $limit = count($matches[0]);
439
        for ($i = 0; $i < $limit; $i++) {
440
            $str = $matches[2][$i];
441
            // context not handled yet
442
            $domain = strpos($start, '__x') === 0 ? $this->defaultDomain : substr(trim($str), 0, strpos($str, ',') - 1);
443
            if (substr_count($matches[2][0], ',') === 1) {
444
                $str = substr(trim(substr($str, strpos($str, ',') + 1)), 1);
445
            } elseif (substr_count($matches[2][0], ',') === 2) {
446
                $str = trim(substr($str, strpos($str, ',') + 1));
447
                $str = trim(substr($str, 0, strpos($str, ',')));
448
                $str = substr($str, 1, -1);
449
            }
450
            $item = $this->fixString($str);
451
452
            if (!array_key_exists($domain, $this->poResult)) {
453
                $this->poResult[$domain] = [];
454
            }
455
456
            if (!in_array($item, $this->poResult[$domain])) {
457
                $this->poResult[$domain][] = $item;
458
            }
459
        }
460
    }
461
462
    /**
463
     * Parse file content and put i18n data in poResult array
464
     *
465
     * @param string $start The starting string to search for, the name of the translation method
466
     * @param string $content The file content
467
     * @param array $options The options
468
     * @return void
469
     */
470
    private function parseContentThirdArg($start, $content, $options): void
471
    {
472
        // phpcs:disable
473
        $rgxp =
474
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . '([^{)}]*)' . "{$options['double_quote']}" .
475
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . '([^{)}]*)' . "{$options['quote']}" .
476
            '/';
477
        // phpcs:enable
478
        $matches = [];
479
        preg_match_all($rgxp, $content, $matches);
480
481
        $domain = $this->defaultDomain; // domain and context not handled yet
482
483
        $limit = count($matches[0]);
484
        for ($i = 0; $i < $limit; $i++) {
485
            $str = $matches[2][$i];
486
            $pos = $this->strposX($str, ',', 2);
487
            $str = trim(substr($str, $pos + 1));
488
            if (strpos($str, ',') > 0) {
489
                $str = substr($str, 1, strpos($str, ',') - 2);
490
            } else {
491
                $str = substr($str, 1);
492
            }
493
            $item = $this->fixString($str);
494
495
            if (!array_key_exists($domain, $this->poResult)) {
496
                $this->poResult[$domain] = [];
497
            }
498
499
            if (!in_array($item, $this->poResult[$domain])) {
500
                $this->poResult[$domain][] = $item;
501
            }
502
        }
503
    }
504
505
    /**
506
     * Calculate nth ($number) position of $needle in $haystack.
507
     *
508
     * @param string $haystack The haystack where to search
509
     * @param string $needle The needle to search
510
     * @param int $number The nth position to retrieve
511
     * @return int|false
512
     */
513
    private function strposX($haystack, $needle, $number = 0)
514
    {
515
        return strpos(
516
            $haystack,
517
            $needle,
518
            $number > 1 ?
519
            $this->strposX($haystack, $needle, $number - 1) + strlen($needle) : 0
520
        );
521
    }
522
523
    /**
524
     * Parse a directory
525
     *
526
     * @param string $dir The directory
527
     * @return void
528
     */
529
    private function parseDir($dir): void
530
    {
531
        $folder = new Folder($dir);
0 ignored issues
show
Deprecated Code introduced by
The class Cake\Filesystem\Folder has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

531
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
532
        $tree = $folder->tree($dir, false);
533
        foreach ($tree as $files) {
534
            foreach ($files as $file) {
535
                if (!is_dir($file)) {
536
                    $f = new File($file);
0 ignored issues
show
Deprecated Code introduced by
The class Cake\Filesystem\File has been deprecated: 4.0.0 Will be removed in 5.0. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

536
                    $f = /** @scrutinizer ignore-deprecated */ new File($file);
Loading history...
537
                    $info = $f->info();
538
                    if (isset($info['extension'])) {
539
                        $this->parseFile($file, $info['extension']);
540
                    }
541
                }
542
            }
543
        }
544
    }
545
546
    /**
547
     * Extract translations from javascript files using ttag, if available.
548
     *
549
     * @param \Cake\Console\Arguments $args The command arguments.
550
     * @param \Cake\Console\ConsoleIo $io The console io
551
     * @return void
552
     * @codeCoverageIgnore
553
     */
554
    private function ttagExtract(Arguments $args, ConsoleIo $io): void
555
    {
556
        // check ttag command exists
557
        $ttag = 'node_modules/ttag-cli/bin/ttag';
558
        if (!file_exists($ttag)) {
559
            $io->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
560
561
            return;
562
        }
563
        // check template folder exists
564
        $appDir = 'src/Template';
565
        $plugin = $args->getOption('plugin');
566
        if (!empty($plugin)) {
567
            $startPath = $args->getOption('startPath') ?? getcwd();
568
            $appDir = sprintf('%s/plugins/%s/src/Template', $startPath, $plugin);
569
        }
570
        if (!file_exists($appDir)) {
571
            $io->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
572
573
            return;
574
        }
575
576
        // do extract translation strings from js files using ttag
577
        $io->out('Extracting translation string from javascript files using ttag');
578
        $masterJs = sprintf('%s/master-js.pot', $this->localePath);
579
        exec(sprintf('%s extract --o %s --l en %s', $ttag, $masterJs, $appDir));
580
581
        // merge master-js.pot and master.pot
582
        $master = sprintf('%s/master.pot', $this->localePath);
583
        exec(sprintf('msgcat --use-first %s %s -o %s', $master, $masterJs, $master));
584
585
        // remove master-js.pot
586
        unlink($masterJs);
587
    }
588
}
589