Passed
Push — master ( ae5b91...c43fb3 )
by Alberto
01:55
created

GettextCommand::ttagExtract()   B

Complexity

Conditions 7
Paths 21

Size

Total Lines 30
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 16
nc 21
nop 2
dl 0
loc 30
rs 8.8333
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\App;
23
use Cake\Core\Configure;
24
use Cake\Core\Plugin;
25
use Cake\Filesystem\File;
26
use Cake\Filesystem\Folder;
27
use Cake\Utility\Hash;
28
use Cake\View\View;
29
30
/**
31
 * Gettext command.
32
 */
33
class GettextCommand extends Command
34
{
35
    /**
36
     * The Po results
37
     *
38
     * @var array
39
     */
40
    protected $poResult = [];
41
42
    /**
43
     * The template paths
44
     *
45
     * @var array
46
     */
47
    protected $templatePaths = [];
48
49
    /**
50
     * The locale path
51
     *
52
     * @var string
53
     */
54
    protected $localePath = null;
55
56
    /**
57
     * The name of default domain if not specified. Used for pot and po file names.
58
     *
59
     * @var string
60
     */
61
    protected $defaultDomain = 'default';
62
63
    /**
64
     * @inheritDoc
65
     */
66
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
67
    {
68
        return parent::buildOptionParser($parser)
69
            ->setDescription([
70
                'Create or update i18n po/pot files',
71
                '',
72
                '`bin/cake gettext`: update files for current app',
73
                '`bin/cake gettext -app <app path>`: update files for the app',
74
                '`bin/cake gettext -plugin <plugin name>`: update files for the plugin',
75
            ])
76
            ->addOption('app', [
77
                'help' => 'The app path, for i18n update.',
78
                'short' => 'a',
79
                'required' => false,
80
            ])
81
            ->addOption('plugin', [
82
                'help' => 'The plugin name, for i18n update.',
83
                'short' => 'p',
84
                'required' => false,
85
            ]);
86
    }
87
88
    /**
89
     * Get po result.
90
     *
91
     * @return array
92
     */
93
    public function getPoResult(): array
94
    {
95
        return $this->poResult;
96
    }
97
98
    /**
99
     * Get templatePaths.
100
     *
101
     * @return array
102
     */
103
    public function getTemplatePaths(): array
104
    {
105
        return $this->templatePaths;
106
    }
107
108
    /**
109
     * Get localePath
110
     *
111
     * @return string
112
     */
113
    public function getLocalePath(): string
114
    {
115
        return $this->localePath;
116
    }
117
118
    /**
119
     * Update gettext po files.
120
     *
121
     * @param \Cake\Console\Arguments $args The command arguments.
122
     * @param \Cake\Console\ConsoleIo $io The console io
123
     * @return null|void|int The exit code or null for success
124
     */
125
    public function execute(Arguments $args, ConsoleIo $io)
126
    {
127
        $resCmd = [];
128
        exec('which msgmerge 2>&1', $resCmd);
129
        if (empty($resCmd[0])) {
130
            $io->abort('ERROR: msgmerge not available. Please install gettext utilities.');
131
        }
132
133
        $io->out('Updating .pot and .po files...');
134
135
        $this->setupPaths($args);
136
        foreach ($this->templatePaths as $path) {
137
            $io->out(sprintf('Search in: %s', $path));
138
            $this->parseDir($path);
139
        }
140
141
        $io->out('Creating master .pot file');
142
        $this->writeMasterPot($io);
143
        $this->ttagExtract($args, $io);
144
145
        $io->hr();
146
        $io->out('Merging master .pot with current .po files');
147
        $io->hr();
148
149
        $this->writePoFiles($io);
150
151
        $io->out('Done');
152
153
        return null;
154
    }
155
156
    /**
157
     * Setup template paths and locale path
158
     *
159
     * @param \Cake\Console\Arguments $args The command arguments.
160
     * @return void
161
     */
162
    private function setupPaths(Arguments $args): void
163
    {
164
        $plugin = $args->getOption('plugin');
165
        $localesPaths = (array)App::path('locales');
166
        if ($plugin && is_string($plugin)) {
167
            $paths = [
168
                Plugin::classPath($plugin),
169
                Plugin::configPath($plugin),
170
            ];
171
            $this->templatePaths = array_merge($paths, App::path(View::NAME_TEMPLATE, $plugin));
172
            $this->defaultDomain = $plugin;
173
            foreach ($localesPaths as $path) {
174
                if (strpos($path, sprintf('%s%s%s', DS, $plugin, DS)) > 0) {
175
                    $this->localePath = $path;
176
                    break;
177
                }
178
            }
179
180
            return;
181
        }
182
        $app = $args->getOption('app');
183
        $basePath = $app ?? getcwd();
184
        $this->templatePaths = [$basePath . DS . 'src', $basePath . DS . 'config'];
185
        $this->templatePaths = array_merge($this->templatePaths, App::path(View::NAME_TEMPLATE));
186
        $this->templatePaths = array_filter($this->templatePaths, function ($path) {
187
            return strpos($path, 'plugins') === false;
188
        });
189
        $this->localePath = (string)Hash::get($localesPaths, 0);
190
    }
191
192
    /**
193
     * Write `master.pot` file
194
     *
195
     * @param \Cake\Console\ConsoleIo $io The console io
196
     * @return void
197
     */
198
    private function writeMasterPot(ConsoleIo $io): void
199
    {
200
        foreach ($this->poResult as $domain => $poResult) {
201
            $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
202
            $io->out(sprintf('Writing new .pot file: %s', $potFilename));
203
            $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

203
            $pot = /** @scrutinizer ignore-deprecated */ new File($potFilename, true);
Loading history...
204
            $pot->write($this->header('pot'));
205
            sort($poResult);
206
            foreach ($poResult as $res) {
207
                if (!empty($res)) {
208
                    $pot->write(sprintf('%smsgid "%s"%smsgstr ""%s', "\n", $res, "\n", "\n"));
209
                }
210
            }
211
            $pot->close();
212
        }
213
    }
214
215
    /**
216
     * Write `.po` files
217
     *
218
     * @param \Cake\Console\ConsoleIo $io The console io
219
     * @return void
220
     */
221
    private function writePoFiles(ConsoleIo $io): void
222
    {
223
        $header = $this->header('po');
224
        $locales = array_keys((array)Configure::read('I18n.locales', []));
225
        foreach ($locales as $loc) {
226
            $potDir = $this->localePath . DS . $loc;
227
            if (!file_exists($potDir)) {
228
                mkdir($potDir);
229
            }
230
            $io->out(sprintf('Language: %s', $loc));
231
232
            foreach (array_keys($this->poResult) as $domain) {
233
                $potFilename = sprintf('%s/%s.pot', $this->localePath, $domain);
234
                $poFile = sprintf('%s/%s.po', $potDir, $domain);
235
                if (!file_exists($poFile)) {
236
                    $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

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

538
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
539
        $tree = $folder->tree($dir, false);
540
        foreach ($tree as $files) {
541
            foreach ($files as $file) {
542
                if (!is_dir($file)) {
543
                    $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

543
                    $f = /** @scrutinizer ignore-deprecated */ new File($file);
Loading history...
544
                    $info = $f->info();
545
                    if (isset($info['extension'])) {
546
                        $this->parseFile($file, $info['extension']);
547
                    }
548
                }
549
            }
550
        }
551
    }
552
553
    /**
554
     * Extract translations from javascript files using ttag, if available.
555
     *
556
     * @param \Cake\Console\Arguments $args The command arguments.
557
     * @param \Cake\Console\ConsoleIo $io The console io
558
     * @return void
559
     * @codeCoverageIgnore
560
     */
561
    private function ttagExtract(Arguments $args, ConsoleIo $io): void
562
    {
563
        // check ttag command exists
564
        $ttag = 'node_modules/ttag-cli/bin/ttag';
565
        if (!file_exists($ttag)) {
566
            $io->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
567
568
            return;
569
        }
570
        // check template folder exists
571
        $plugin = $args->getOption('plugin');
572
        $appDir = !empty($plugin) && is_string($plugin) ? Plugin::templatePath($plugin) : Hash::get(App::path(View::NAME_TEMPLATE), 0);
573
        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

573
        if (!file_exists(/** @scrutinizer ignore-type */ $appDir)) {
Loading history...
574
            $io->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
575
576
            return;
577
        }
578
579
        // do extract translation strings from js files using ttag
580
        $io->out('Extracting translation string from javascript files using ttag');
581
        $defaultJs = sprintf('%s/default-js.pot', $this->localePath);
582
        exec(sprintf('%s extract --o %s --l en %s', $ttag, $defaultJs, $appDir));
583
584
        // merge default-js.pot and <plugin>.pot|default.pot
585
        $potFile = !empty($plugin) && is_string($plugin) ? sprintf('%s.pot', $plugin) : 'default.pot';
586
        $default = sprintf('%s/%s', $this->localePath, $potFile);
587
        exec(sprintf('msgcat --use-first %s %s -o %s', $default, $defaultJs, $default));
588
589
        // remove default-js.pot
590
        unlink($defaultJs);
591
    }
592
}
593