Completed
Push — master ( a26fda...8a6968 )
by Alberto
27s queued 12s
created

GettextCommand::writePoFiles()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 24
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 19
nc 7
nop 1
dl 0
loc 24
rs 9.3222
c 0
b 0
f 0
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
        $capturePath = "([^']*)',\s*'([^']*)";
430
        $doubleQuoteCapture = str_replace("'", $options['double_quote'], $capturePath);
431
        $quoteCapture = str_replace("'", $options['quote'], $capturePath);
432
433
        // phpcs:disable
434
        $rgxp =
435
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . $doubleQuoteCapture . "{$options['double_quote']}" .
436
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . $quoteCapture . "{$options['quote']}" .
437
            '/';
438
        // phpcs:enable
439
        $matches = [];
440
        preg_match_all($rgxp, $content, $matches);
441
442
        $limit = count($matches[0]);
443
        for ($i = 0; $i < $limit; $i++) {
444
            $domain = $matches[3][$i];
445
            $str = $matches[4][$i];
446
447
            // context not handled for now
448
            if (strpos($start, '__x') === 0) {
449
                $domain = $this->defaultDomain;
450
            }
451
452
            $item = $this->fixString($str);
453
454
            if (!array_key_exists($domain, $this->poResult)) {
455
                $this->poResult[$domain] = [];
456
            }
457
458
            if (!in_array($item, $this->poResult[$domain])) {
459
                $this->poResult[$domain][] = $item;
460
            }
461
        }
462
    }
463
464
    /**
465
     * Parse file content and put i18n data in poResult array
466
     *
467
     * @param string $start The starting string to search for, the name of the translation method
468
     * @param string $content The file content
469
     * @param array $options The options
470
     * @return void
471
     */
472
    private function parseContentThirdArg($start, $content, $options): void
473
    {
474
        // phpcs:disable
475
        $rgxp =
476
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . '([^{)}]*)' . "{$options['double_quote']}" .
477
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . '([^{)}]*)' . "{$options['quote']}" .
478
            '/';
479
        // phpcs:enable
480
        $matches = [];
481
        preg_match_all($rgxp, $content, $matches);
482
483
        $domain = $this->defaultDomain; // domain and context not handled yet
484
485
        $limit = count($matches[0]);
486
        for ($i = 0; $i < $limit; $i++) {
487
            $str = $matches[2][$i];
488
            $pos = $this->strposX($str, ',', 2);
489
            $str = trim(substr($str, $pos + 1));
490
            if (strpos($str, ',') > 0) {
491
                $str = substr($str, 1, strpos($str, ',') - 2);
492
            } else {
493
                $str = substr($str, 1);
494
            }
495
            $item = $this->fixString($str);
496
497
            if (!array_key_exists($domain, $this->poResult)) {
498
                $this->poResult[$domain] = [];
499
            }
500
501
            if (!in_array($item, $this->poResult[$domain])) {
502
                $this->poResult[$domain][] = $item;
503
            }
504
        }
505
    }
506
507
    /**
508
     * Calculate nth ($number) position of $needle in $haystack.
509
     *
510
     * @param string $haystack The haystack where to search
511
     * @param string $needle The needle to search
512
     * @param int $number The nth position to retrieve
513
     * @return int|false
514
     */
515
    private function strposX($haystack, $needle, $number = 0)
516
    {
517
        return strpos(
518
            $haystack,
519
            $needle,
520
            $number > 1 ?
521
            $this->strposX($haystack, $needle, $number - 1) + strlen($needle) : 0
522
        );
523
    }
524
525
    /**
526
     * Parse a directory
527
     *
528
     * @param string $dir The directory
529
     * @return void
530
     */
531
    private function parseDir($dir): void
532
    {
533
        $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

533
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
534
        $tree = $folder->tree($dir, false);
535
        foreach ($tree as $files) {
536
            foreach ($files as $file) {
537
                if (!is_dir($file)) {
538
                    $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

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