Passed
Push — master ( 06b065...0e0687 )
by Stefano
01:04
created

GettextCommand::writePoFiles()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 29
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 21
c 0
b 0
f 0
nc 8
nop 1
dl 0
loc 29
rs 8.9617
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\App;
23
use Cake\Core\Configure;
24
use Cake\Core\Plugin;
25
use Cake\Filesystem\File;
26
use Cake\Filesystem\Folder;
27
use Cake\I18n\FrozenTime;
28
use Cake\Utility\Hash;
29
use Cake\View\View;
30
31
/**
32
 * Gettext command.
33
 */
34
class GettextCommand extends Command
35
{
36
    /**
37
     * @var int
38
     */
39
    public const CODE_CHANGES = 2;
40
41
    /**
42
     * The Po results
43
     *
44
     * @var array
45
     */
46
    protected $poResult = [];
47
48
    /**
49
     * The template paths
50
     *
51
     * @var array
52
     */
53
    protected $templatePaths = [];
54
55
    /**
56
     * The locale path
57
     *
58
     * @var string
59
     */
60
    protected $localePath = null;
61
62
    /**
63
     * The name of default domain if not specified. Used for pot and po file names.
64
     *
65
     * @var string
66
     */
67
    protected $defaultDomain = 'default';
68
69
    /**
70
     * The locales to generate.
71
     *
72
     * @var array
73
     */
74
    protected $locales = [];
75
76
    /**
77
     * @inheritDoc
78
     */
79
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
80
    {
81
        return parent::buildOptionParser($parser)
82
            ->setDescription([
83
                'Create or update i18n po/pot files',
84
                '',
85
                '`bin/cake gettext`: update files for current app',
86
                '`bin/cake gettext -app <app path>`: update files for the app',
87
                '`bin/cake gettext -plugin <plugin name>`: update files for the plugin',
88
            ])
89
            ->addOption('app', [
90
                'help' => 'The app path, for i18n update.',
91
                'short' => 'a',
92
                'required' => false,
93
            ])
94
            ->addOption('plugin', [
95
                'help' => 'The plugin name, for i18n update.',
96
                'short' => 'p',
97
                'required' => false,
98
            ])
99
            ->addOption('ci', [
100
                'help' => 'Run in CI mode. Exit with error if PO files are changed.',
101
                'required' => false,
102
                'boolean' => true,
103
            ])
104
            ->addOption('locales', [
105
                'help' => 'Comma separated list of locales to generate. Leave empty to use configuration `I18n.locales`',
106
                'short' => 'l',
107
                'default' => implode(',', array_keys((array)Configure::read('I18n.locales'))),
108
            ]);
109
    }
110
111
    /**
112
     * Get po result.
113
     *
114
     * @return array
115
     */
116
    public function getPoResult(): array
117
    {
118
        return $this->poResult;
119
    }
120
121
    /**
122
     * Get templatePaths.
123
     *
124
     * @return array
125
     */
126
    public function getTemplatePaths(): array
127
    {
128
        return $this->templatePaths;
129
    }
130
131
    /**
132
     * Get localePath
133
     *
134
     * @return string
135
     */
136
    public function getLocalePath(): string
137
    {
138
        return $this->localePath;
139
    }
140
141
    /**
142
     * Update gettext po files.
143
     *
144
     * @param \Cake\Console\Arguments $args The command arguments.
145
     * @param \Cake\Console\ConsoleIo $io The console io
146
     * @return null|void|int The exit code or null for success
147
     */
148
    public function execute(Arguments $args, ConsoleIo $io)
149
    {
150
        $resCmd = [];
151
        exec('which msgmerge 2>&1', $resCmd);
152
        if (empty($resCmd[0])) {
153
            $io->abort('ERROR: msgmerge not available. Please install gettext utilities.');
154
        }
155
156
        $io->out('Updating .pot and .po files...');
157
158
        $this->locales = array_filter(explode(',', $args->getOption('locales')));
0 ignored issues
show
Bug introduced by
It seems like $args->getOption('locales') can also be of type boolean and null; however, parameter $string of explode() does only seem to accept 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

158
        $this->locales = array_filter(explode(',', /** @scrutinizer ignore-type */ $args->getOption('locales')));
Loading history...
159
        $this->setupPaths($args);
160
        foreach ($this->templatePaths as $path) {
161
            $io->out(sprintf('Search in: %s', $path));
162
            $this->parseDir($path);
163
        }
164
165
        $io->out('Creating master .pot file');
166
        $hasChanges = $this->writeMasterPot($io);
167
        $this->ttagExtract($args, $io);
168
169
        $io->hr();
170
        $io->out('Merging master .pot with current .po files');
171
        $io->hr();
172
173
        $this->writePoFiles($io);
174
175
        $io->out('Done');
176
177
        if ($args->getOption('ci') && $hasChanges) {
178
            return GettextCommand::CODE_CHANGES;
179
        }
180
181
        return GettextCommand::CODE_SUCCESS;
182
    }
183
184
    /**
185
     * Setup template paths and locale path
186
     *
187
     * @param \Cake\Console\Arguments $args The command arguments.
188
     * @return void
189
     */
190
    private function setupPaths(Arguments $args): void
191
    {
192
        $plugin = $args->getOption('plugin');
193
        $localesPaths = (array)App::path('locales');
194
        if ($plugin && is_string($plugin)) {
195
            $paths = [
196
                Plugin::classPath($plugin),
197
                Plugin::configPath($plugin),
198
            ];
199
            $this->templatePaths = array_merge($paths, App::path(View::NAME_TEMPLATE, $plugin));
200
            $this->defaultDomain = $plugin;
201
            $localePaths = App::path('locales', $plugin);
202
            $this->localePath = (string)Hash::get($localePaths, '0');
203
204
            return;
205
        }
206
        $app = $args->getOption('app');
207
        $basePath = $app ?? getcwd();
208
        $this->templatePaths = [$basePath . DS . 'src', $basePath . DS . 'config'];
209
        $this->templatePaths = array_merge($this->templatePaths, App::path(View::NAME_TEMPLATE));
210
        $this->templatePaths = array_filter($this->templatePaths, function ($path) {
211
            return strpos($path, 'plugins') === false;
212
        });
213
        $this->localePath = (string)Hash::get($localesPaths, 0);
214
    }
215
216
    /**
217
     * Write `master.pot` file
218
     *
219
     * @param \Cake\Console\ConsoleIo $io The console io
220
     * @return bool True if file was updated, false otherwise
221
     */
222
    private function writeMasterPot(ConsoleIo $io): bool
223
    {
224
        $updated = false;
225
226
        foreach ($this->poResult as $domain => $poResult) {
227
            $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
228
            $io->out(sprintf('Writing new .pot file: %s', $potFilename));
229
            $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

229
            $pot = /** @scrutinizer ignore-deprecated */ new File($potFilename, true);
Loading history...
230
231
            $contents = $pot->read();
232
233
            // remove headers from pot file
234
            $contents = preg_replace('/^msgid ""\nmsgstr ""/', '', $contents);
235
            $contents = trim(preg_replace('/^"([^"]*?)"$/m', '', $contents));
236
237
            $lines = [];
238
            ksort($poResult);
239
            foreach ($poResult as $res => $contexts) {
240
                sort($contexts);
241
                foreach ($contexts as $ctx) {
242
                    if (!empty($ctx)) {
243
                        $lines[] = sprintf('msgctxt "%s"%smsgid "%s"%smsgstr ""', $ctx, "\n", $res, "\n");
244
                    } else {
245
                        $lines[] = sprintf('msgid "%s"%smsgstr ""', $res, "\n");
246
                    }
247
                }
248
            }
249
250
            $result = implode("\n\n", $lines);
251
            if ($contents !== $result) {
252
                $pot->write(sprintf("%s\n%s\n", $this->header('pot'), $result));
253
                $updated = true;
254
            }
255
256
            $pot->close();
257
        }
258
259
        return $updated;
260
    }
261
262
    /**
263
     * Write `.po` files
264
     *
265
     * @param \Cake\Console\ConsoleIo $io The console io
266
     * @return void
267
     */
268
    private function writePoFiles(ConsoleIo $io): void
269
    {
270
        if (empty($this->locales)) {
271
            $io->info('No locales set, .po files generation skipped');
272
273
            return;
274
        }
275
276
        $header = $this->header('po');
277
        foreach ($this->locales as $loc) {
278
            $potDir = $this->localePath . DS . $loc;
279
            if (!file_exists($potDir)) {
280
                mkdir($potDir);
281
            }
282
            $io->out(sprintf('Language: %s', $loc));
283
284
            foreach (array_keys($this->poResult) as $domain) {
285
                $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
286
                $poFile = sprintf('%s/%s.po', $potDir, $domain);
287
                if (!file_exists($poFile)) {
288
                    $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

288
                    $newPoFile = /** @scrutinizer ignore-deprecated */ new File($poFile, true);
Loading history...
289
                    $newPoFile->write($header);
290
                    $newPoFile->close();
291
                }
292
                $io->out(sprintf('Merging %s', $poFile));
293
                $mergeCmd = sprintf('msgmerge --backup=off -N -U %s %s', $poFile, $potFilename);
294
                exec($mergeCmd);
295
                $this->analyzePoFile($poFile, $io);
296
                $io->hr();
297
            }
298
        }
299
    }
300
301
    /**
302
     * Header lines for po/pot file
303
     *
304
     * @param string $type The file type (can be 'po', 'pot')
305
     * @return string
306
     * @codeCoverageIgnore
307
     */
308
    private function header(string $type = 'po'): string
309
    {
310
        $result = sprintf('msgid ""%smsgstr ""%s', "\n", "\n");
311
        $contents = [
312
            'po' => [
313
                'Project-Id-Version' => 'BEdita 4',
314
                'POT-Creation-Date' => FrozenTime::now()->format('Y-m-d H:i:s'),
315
                'PO-Revision-Date' => '',
316
                'Last-Translator' => '',
317
                'Language-Team' => 'BEdita I18N & I10N Team',
318
                'Language' => '',
319
                'MIME-Version' => '1.0',
320
                'Content-Transfer-Encoding' => '8bit',
321
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
322
                'Content-Type' => 'text/plain; charset=utf-8',
323
            ],
324
            'pot' => [
325
                'Project-Id-Version' => 'BEdita 4',
326
                'POT-Creation-Date' => FrozenTime::now()->format('Y-m-d H:i:s'),
327
                'MIME-Version' => '1.0',
328
                'Content-Transfer-Encoding' => '8bit',
329
                'Language-Team' => 'BEdita I18N & I10N Team',
330
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
331
                'Content-Type' => 'text/plain; charset=utf-8',
332
            ],
333
        ];
334
        foreach ($contents[$type] as $k => $v) {
335
            $result .= sprintf('"%s: %s \n"', $k, $v) . "\n";
336
        }
337
338
        return $result;
339
    }
340
341
    /**
342
     * Analyze po file and translate it
343
     *
344
     * @param string $filename The po file name
345
     * @param \Cake\Console\ConsoleIo $io The console io
346
     * @return void
347
     */
348
    private function analyzePoFile($filename, ConsoleIo $io): void
349
    {
350
        $lines = file($filename);
351
        $numItems = $numNotTranslated = 0;
352
        foreach ($lines as $k => $l) {
353
            if (strpos($l, 'msgid "') === 0) {
354
                $numItems++;
355
            }
356
            if (strpos($l, 'msgstr ""') === 0) {
357
                if (!isset($lines[$k + 1])) {
358
                    $numNotTranslated++;
359
                } elseif (strpos($lines[$k + 1], '"') !== 0) {
360
                    $numNotTranslated++;
361
                }
362
            }
363
        }
364
        $translated = $numItems - $numNotTranslated;
365
        $percent = 0;
366
        if ($numItems > 0) {
367
            $percent = number_format($translated * 100. / $numItems, 1);
368
        }
369
        $io->out(sprintf('Translated %d of %d items - %s %%', $translated, $numItems, $percent));
370
    }
371
372
    /**
373
     * Remove leading and trailing quotes from string
374
     *
375
     * @param string $str The string
376
     * @return string The new string
377
     */
378
    private function unquoteString($str): string
379
    {
380
        return substr($str, 1, -1);
381
    }
382
383
    /**
384
     * "fix" string - strip slashes, escape and convert new lines to \n
385
     *
386
     * @param string $str The string
387
     * @return string The new string
388
     */
389
    private function fixString($str): string
390
    {
391
        $str = stripslashes($str);
392
        $str = str_replace('"', '\"', $str);
393
        $str = str_replace("\n", '\n', $str);
394
        $str = str_replace('|||||', "'", $str); // special sequence used in parseContent to temporarily replace "\'"
395
396
        return $str;
397
    }
398
399
    /**
400
     * Parse file and rips gettext strings
401
     *
402
     * @param string $file The file name
403
     * @param string $extension The file extension
404
     * @return void
405
     */
406
    private function parseFile($file, $extension)
407
    {
408
        if (!in_array($extension, ['php', 'twig'])) {
409
            return;
410
        }
411
        $content = file_get_contents($file);
412
        if (empty($content)) {
413
            return;
414
        }
415
416
        $functions = [
417
            '__' => 0, // __( string $singular , ... $args )
418
            '__n' => 0, // __n( string $singular , string $plural , integer $count , ... $args )
419
            '__d' => 1, // __d( string $domain , string $msg , ... $args )
420
            '__dn' => 1, // __dn( string $domain , string $singular , string $plural , integer $count , ... $args )
421
            '__x' => 1, // __x( string $context , string $singular , ... $args )
422
            '__xn' => 1, // __xn( string $context , string $singular , string $plural , integer $count , ... $args )
423
            '__dx' => 2, // __dx( string $domain , string $context , string $msg , ... $args )
424
            '__dxn' => 2, // __dxn( string $domain , string $context , string $singular , string $plural , integer $count , ... $args )
425
        ];
426
427
        // temporarily replace "\'" with "|||||", fixString will replace "|||||" with "\'"
428
        // this fixes wrongly matched data in the following regexp
429
        $content = str_replace("\'", '|||||', $content);
430
431
        $options = [
432
            'open_parenthesis' => preg_quote('('),
433
            'quote' => preg_quote("'"),
434
            'double_quote' => preg_quote('"'),
435
        ];
436
437
        foreach ($functions as $fname => $singularPosition) {
438
            $capturePath = "'[^']*'";
439
            $doubleQuoteCapture = str_replace("'", $options['double_quote'], $capturePath);
440
            $quoteCapture = str_replace("'", $options['quote'], $capturePath);
441
442
            // phpcs:disable
443
            $rgxp = '/' . $fname . '\s*' . $options['open_parenthesis'] . str_repeat('((?:' . $doubleQuoteCapture . ')|(?:' . $quoteCapture . '))\s*[,)]\s*', $singularPosition + 1) . '/';
444
            // phpcs:enable
445
446
            $matches = [];
447
            preg_match_all($rgxp, $content, $matches);
448
449
            $limit = count($matches[0]);
450
            for ($i = 0; $i < $limit; $i++) {
451
                $domain = $this->defaultDomain;
452
                $ctx = '';
453
                $str = $this->unquoteString($matches[1][$i]);
454
455
                if (strpos($fname, '__d') === 0) {
456
                    $domain = $this->unquoteString($matches[1][$i]);
457
458
                    if (strpos($fname, '__dx') === 0) {
459
                        $ctx = $this->unquoteString($matches[2][$i]);
460
                        $str = $this->unquoteString($matches[3][$i]);
461
                    } else {
462
                        $str = $this->unquoteString($matches[2][$i]);
463
                    }
464
                } elseif (strpos($fname, '__x') === 0) {
465
                    $ctx = $this->unquoteString($matches[1][$i]);
466
                    $str = $this->unquoteString($matches[2][$i]);
467
                }
468
469
                $str = $this->fixString($str);
470
                if (empty($str)) {
471
                    continue;
472
                }
473
474
                if (!array_key_exists($domain, $this->poResult)) {
475
                    $this->poResult[$domain] = [];
476
                }
477
478
                if (!array_key_exists($str, $this->poResult[$domain])) {
479
                    $this->poResult[$domain][$str] = [''];
480
                }
481
482
                if (!in_array($ctx, $this->poResult[$domain][$str])) {
483
                    $this->poResult[$domain][$str][] = $ctx;
484
                }
485
            }
486
        }
487
    }
488
489
    /**
490
     * Parse a directory
491
     *
492
     * @param string $dir The directory
493
     * @return void
494
     */
495
    private function parseDir($dir): void
496
    {
497
        $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

497
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
498
        $tree = $folder->tree($dir, false);
499
        foreach ($tree as $files) {
500
            foreach ($files as $file) {
501
                if (!is_dir($file)) {
502
                    $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

502
                    $f = /** @scrutinizer ignore-deprecated */ new File($file);
Loading history...
503
                    $info = $f->info();
504
                    if (isset($info['extension'])) {
505
                        $this->parseFile($file, $info['extension']);
506
                    }
507
                }
508
            }
509
        }
510
    }
511
512
    /**
513
     * Extract translations from javascript files using ttag, if available.
514
     *
515
     * @param \Cake\Console\Arguments $args The command arguments.
516
     * @param \Cake\Console\ConsoleIo $io The console io
517
     * @return void
518
     * @codeCoverageIgnore
519
     */
520
    private function ttagExtract(Arguments $args, ConsoleIo $io): void
521
    {
522
        // check ttag command exists
523
        $ttag = 'node_modules/ttag-cli/bin/ttag';
524
        if (!file_exists($ttag)) {
525
            $io->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
526
527
            return;
528
        }
529
        // check template folder exists
530
        $plugin = $args->getOption('plugin');
531
        $appDir = !empty($plugin) && is_string($plugin) ? Plugin::templatePath($plugin) : Hash::get(App::path(View::NAME_TEMPLATE), 0);
532
        if (!file_exists($appDir)) {
0 ignored issues
show
Bug introduced by
It seems like $appDir can also be of type null; however, parameter $filename of file_exists() does only seem to accept 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

532
        if (!file_exists(/** @scrutinizer ignore-type */ $appDir)) {
Loading history...
533
            $io->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
534
535
            return;
536
        }
537
        // Path to the resources directory defined in cakephp app config/paths.php
538
        // Do not add RESOURCES path when it's a plugin
539
        if (empty($plugin) && defined('RESOURCES') && file_exists(RESOURCES)) {
0 ignored issues
show
Bug introduced by
The constant BEdita\I18n\Command\RESOURCES was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
540
            $appDir = sprintf('%s %s', $appDir, RESOURCES);
541
        }
542
543
        // do extract translation strings from js files using ttag
544
        $io->out('Extracting translation string from javascript files using ttag');
545
        $defaultJs = sprintf('%s/default-js.pot', $this->localePath);
546
        exec(sprintf('%s extract --extractLocation never --o %s --l en %s', $ttag, $defaultJs, $appDir));
547
548
        // merge default-js.pot and <plugin>.pot|default.pot
549
        $potFile = !empty($plugin) && is_string($plugin) ? sprintf('%s.pot', $plugin) : 'default.pot';
550
        $default = sprintf('%s/%s', $this->localePath, $potFile);
551
        exec(sprintf('msgcat --use-first %s %s -o %s', $default, $defaultJs, $default));
552
553
        // remove default-js.pot
554
        unlink($defaultJs);
555
    }
556
}
557