Passed
Pull Request — master (#62)
by Edoardo
01:59
created

GettextCommand::unquoteString()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

453
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
454
        $tree = $folder->tree($dir, false);
455
        foreach ($tree as $files) {
456
            foreach ($files as $file) {
457
                if (!is_dir($file)) {
458
                    $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

458
                    $f = /** @scrutinizer ignore-deprecated */ new File($file);
Loading history...
459
                    $info = $f->info();
460
                    if (isset($info['extension'])) {
461
                        $this->parseFile($file, $info['extension']);
462
                    }
463
                }
464
            }
465
        }
466
    }
467
468
    /**
469
     * Extract translations from javascript files using ttag, if available.
470
     *
471
     * @param \Cake\Console\Arguments $args The command arguments.
472
     * @param \Cake\Console\ConsoleIo $io The console io
473
     * @return void
474
     * @codeCoverageIgnore
475
     */
476
    private function ttagExtract(Arguments $args, ConsoleIo $io): void
477
    {
478
        // check ttag command exists
479
        $ttag = 'node_modules/ttag-cli/bin/ttag';
480
        if (!file_exists($ttag)) {
481
            $io->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
482
483
            return;
484
        }
485
        // check template folder exists
486
        $plugin = $args->getOption('plugin');
487
        $appDir = !empty($plugin) && is_string($plugin) ? Plugin::templatePath($plugin) : Hash::get(App::path(View::NAME_TEMPLATE), 0);
488
        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

488
        if (!file_exists(/** @scrutinizer ignore-type */ $appDir)) {
Loading history...
489
            $io->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
490
491
            return;
492
        }
493
494
        // do extract translation strings from js files using ttag
495
        $io->out('Extracting translation string from javascript files using ttag');
496
        $defaultJs = sprintf('%s/default-js.pot', $this->localePath);
497
        exec(sprintf('%s extract --extractLocation never --o %s --l en %s', $ttag, $defaultJs, $appDir));
498
499
        // merge default-js.pot and <plugin>.pot|default.pot
500
        $potFile = !empty($plugin) && is_string($plugin) ? sprintf('%s.pot', $plugin) : 'default.pot';
501
        $default = sprintf('%s/%s', $this->localePath, $potFile);
502
        exec(sprintf('msgcat --use-first %s %s -o %s', $default, $defaultJs, $default));
503
504
        // remove default-js.pot
505
        unlink($defaultJs);
506
    }
507
}
508