Passed
Push — master ( f35666...49b7e7 )
by Stefano
02:07
created

GettextShell::getPoName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2019 ChannelWeb 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\Shell;
17
18
use Cake\Console\ConsoleOptionParser;
19
use Cake\Console\Shell;
20
use Cake\Core\Configure;
21
use Cake\Filesystem\File;
22
use Cake\Filesystem\Folder;
23
use Cake\Utility\Hash;
24
25
/**
26
 * Gettext shell
27
 */
28
class GettextShell extends Shell
0 ignored issues
show
Deprecated Code introduced by
The class Cake\Console\Shell has been deprecated: 3.6.0 ShellDispatcher and Shell 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

28
class GettextShell extends /** @scrutinizer ignore-deprecated */ Shell
Loading history...
29
{
30
    /**
31
     * Get the option parser for this shell.
32
     *
33
     * @return \Cake\Console\ConsoleOptionParser
34
     * @codeCoverageIgnore
35
     */
36
    public function getOptionParser(): ConsoleOptionParser
37
    {
38
        $parser = parent::getOptionParser();
39
        $parser->addSubcommand('update', [
40
            'help' => 'Update po and pot files',
41
            'parser' => [
42
                'description' => [
43
                    'Create or update i18n po/pot files',
44
                    '',
45
                    '`cake gettext update`: update files for current app',
46
                    '`cake gettext update -app <app path>`: update files for the app',
47
                    '`cake gettext update -plugin <plugin name>`: update files for the plugin',
48
                ],
49
                'options' => [
50
                    'app' => [
51
                        'help' => 'The app path, for i18n update.',
52
                        'short' => 'a',
53
                        'required' => false,
54
                    ],
55
                    'plugin' => [
56
                        'help' => 'The plugin name, for i18n update.',
57
                        'short' => 'p',
58
                        'required' => false,
59
                    ],
60
                ],
61
            ],
62
        ]);
63
64
        return $parser;
65
    }
66
67
    /**
68
     * The Po results
69
     *
70
     * @var array
71
     */
72
    protected $poResult = [];
73
74
    /**
75
     * The template paths
76
     *
77
     * @var array
78
     */
79
    protected $templatePaths = [];
80
81
    /**
82
     * The locale path
83
     *
84
     * @var string
85
     */
86
    protected $localePath = null;
87
88
    /**
89
     * PO file name
90
     *
91
     * @var string
92
     */
93
    protected $poName = 'default.po';
94
95
    /**
96
     * Get po result
97
     */
98
    public function getPoResult(): array
99
    {
100
        return $this->poResult;
101
    }
102
103
    /**
104
     * Get templatePaths
105
     */
106
    public function getTemplatePaths(): array
107
    {
108
        return $this->templatePaths;
109
    }
110
111
    /**
112
     * Get localePath
113
     */
114
    public function getLocalePath(): string
115
    {
116
        return $this->localePath;
117
    }
118
119
    /**
120
     * Get po name
121
     */
122
    public function getPoName(): string
123
    {
124
        return $this->poName;
125
    }
126
127
    /**
128
     * Update gettext po files
129
     *
130
     * @return void
131
     */
132
    public function update(): void
133
    {
134
        $resCmd = [];
135
        exec('which msgmerge 2>&1', $resCmd);
136
        if (empty($resCmd[0])) {
137
            $this->out('ERROR: msgmerge not available. Please install gettext utilities.');
138
139
            return;
140
        }
141
142
        $this->out('Updating .pot and .po files...');
143
144
        $this->setupPaths();
145
        foreach ($this->templatePaths as $path) {
146
            $this->out(sprintf('Search in: %s', $path));
147
            $this->parseDir($path);
148
        }
149
150
        $this->out('Creating master .pot file');
151
        $this->writeMasterPot();
152
        $this->ttagExtract();
153
154
        $this->hr();
155
        $this->out('Merging master .pot with current .po files');
156
        $this->hr();
157
158
        $this->writePoFiles();
159
160
        $this->out('Done');
161
    }
162
163
    /**
164
     * Setup template paths and locale path
165
     *
166
     * @return void
167
     */
168
    private function setupPaths(): void
169
    {
170
        $appTemplates = (array)Configure::read('App.paths.templates');
171
        if (isset($this->params['plugin'])) {
172
            $f = new Folder(sprintf('%s%s', (string)Configure::read('App.paths.plugins.0'), $this->params['plugin']));
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

172
            $f = /** @scrutinizer ignore-deprecated */ new Folder(sprintf('%s%s', (string)Configure::read('App.paths.plugins.0'), $this->params['plugin']));
Loading history...
173
            $basePath = $f->path;
174
            $this->poName = $this->params['plugin'] . '.po';
175
            $this->templatePaths = [$basePath . '/src', $basePath . '/config'];
176
            $appTemplatePath = (string)Hash::get($appTemplates, '1');
177
            if (strpos($appTemplatePath, $basePath . '/src') === false) {
178
                $this->templatePaths[] = $appTemplatePath;
179
            }
180
            $this->localePath = (string)Configure::read('App.paths.locales.1');
181
182
            return;
183
        }
184
        $app = $this->params['app'] ?? getcwd();
185
        $f = new Folder($app);
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

185
        $f = /** @scrutinizer ignore-deprecated */ new Folder($app);
Loading history...
186
        $basePath = $f->path;
187
        $this->templatePaths = [$basePath . '/src', $basePath . '/config'];
188
        $appTemplatePath = (string)Hash::get($appTemplates, '0');
189
        if (strpos($appTemplatePath, $basePath . '/src') === false) {
190
            $this->templatePaths[] = $appTemplatePath;
191
        }
192
        $this->localePath = (string)Configure::read('App.paths.locales.0');
193
    }
194
195
    /**
196
     * Write `master.pot` file
197
     *
198
     * @return void
199
     */
200
    private function writeMasterPot(): void
201
    {
202
        $potFilename = sprintf('%s/master.pot', $this->localePath);
203
        $this->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
        sort($this->poResult);
207
        foreach ($this->poResult as $res) {
208
            if (!empty($res)) {
209
                $pot->write(sprintf('%smsgid "%s"%smsgstr ""%s', "\n", $res, "\n", "\n"));
210
            }
211
        }
212
        $pot->close();
213
    }
214
215
    /**
216
     * Write `.po` files
217
     *
218
     * @return void
219
     */
220
    private function writePoFiles(): void
221
    {
222
        $header = $this->header('po');
223
        $potFilename = sprintf('%s/master.pot', $this->localePath);
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
            $this->out(sprintf('Language: %s', $loc));
231
            $poFile = sprintf('%s/%s', $potDir, $this->poName);
232
            if (!file_exists($poFile)) {
233
                $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

233
                $newPoFile = /** @scrutinizer ignore-deprecated */ new File($poFile, true);
Loading history...
234
                $newPoFile->write($header);
235
                $newPoFile->close();
236
            }
237
            $this->out(sprintf('Merging %s', $poFile));
238
            $mergeCmd = sprintf('msgmerge --backup=off -N -U %s %s', $poFile, $potFilename);
239
            exec($mergeCmd);
240
            $this->analyzePoFile($poFile);
241
            $this->hr();
242
        }
243
    }
244
245
    /**
246
     * Header lines for po/pot file
247
     *
248
     * @param string $type The file type (can be 'po', 'pot')
249
     * @return string
250
     * @codeCoverageIgnore
251
     */
252
    private function header(string $type = 'po'): string
253
    {
254
        $result = sprintf('msgid ""%smsgstr ""%s', "\n", "\n");
255
        $contents = [
256
            'po' => [
257
                'Project-Id-Version' => 'BEdita 4',
258
                'POT-Creation-Date' => date('Y-m-d H:i:s'),
259
                'PO-Revision-Date' => '',
260
                'Last-Translator' => '',
261
                'Language-Team' => 'BEdita I18N & I10N Team',
262
                'Language' => '',
263
                'MIME-Version' => '1.0',
264
                'Content-Transfer-Encoding' => '8bit',
265
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
266
                'Content-Type' => 'text/plain; charset=utf-8',
267
            ],
268
            'pot' => [
269
                'Project-Id-Version' => 'BEdita 4',
270
                'POT-Creation-Date' => date('Y-m-d H:i:s'),
271
                'MIME-Version' => '1.0',
272
                'Content-Transfer-Encoding' => '8bit',
273
                'Language-Team' => 'BEdita I18N & I10N Team',
274
                'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
275
                'Content-Type' => 'text/plain; charset=utf-8',
276
            ],
277
        ];
278
        foreach ($contents[$type] as $k => $v) {
279
            $result .= sprintf('"%s: %s \n"', $k, $v) . "\n";
280
        }
281
282
        return $result;
283
    }
284
285
    /**
286
     * Analyze po file and translate it
287
     *
288
     * @param string $filename The po file name
289
     * @return void
290
     */
291
    private function analyzePoFile($filename): 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
        $this->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
        $limit = count($matches[0]);
401
        for ($i = 0; $i < $limit; $i++) {
402
            $item = $this->fixString($matches[1][$i]);
403
            if (empty($item)) {
404
                $item = $this->fixString($matches[2][$i]);
405
            }
406
            if (!in_array($item, $this->poResult)) {
407
                $this->poResult[] = $item;
408
            }
409
        }
410
    }
411
412
    /**
413
     * Parse file content and put i18n data in poResult array
414
     *
415
     * @param string $start The starting string to search for, the name of the translation method
416
     * @param string $content The file content
417
     * @param array $options The options
418
     * @return void
419
     */
420
    private function parseContentSecondArg($start, $content, $options): void
421
    {
422
        // phpcs:disable
423
        $rgxp =
424
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . '([^{)}]*)' . "{$options['double_quote']}" .
425
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . '([^{)}]*)' . "{$options['quote']}" .
426
            '/';
427
        // phpcs:enable
428
        $matches = [];
429
        preg_match_all($rgxp, $content, $matches);
430
431
        $limit = count($matches[0]);
432
        for ($i = 0; $i < $limit; $i++) {
433
            $str = $matches[2][$i];
434
            if (substr_count($matches[2][0], ',') === 1) {
435
                $str = substr(trim(substr($str, strpos($str, ',') + 1)), 1);
436
            } elseif (substr_count($matches[2][0], ',') === 2) {
437
                $str = trim(substr($str, strpos($str, ',') + 1));
438
                $str = trim(substr($str, 0, strpos($str, ',')));
439
                $str = substr($str, 1, -1);
440
            }
441
            $item = $this->fixString($str);
442
            if (!in_array($item, $this->poResult)) {
443
                $this->poResult[] = $item;
444
            }
445
        }
446
    }
447
448
    /**
449
     * Parse file content and put i18n data in poResult array
450
     *
451
     * @param string $start The starting string to search for, the name of the translation method
452
     * @param string $content The file content
453
     * @param array $options The options
454
     * @return void
455
     */
456
    private function parseContentThirdArg($start, $content, $options): void
457
    {
458
        // phpcs:disable
459
        $rgxp =
460
            '/' . "${start}\s*{$options['open_parenthesis']}\s*{$options['double_quote']}" . '([^{)}]*)' . "{$options['double_quote']}" .
461
            '|' . "${start}\s*{$options['open_parenthesis']}\s*{$options['quote']}" . '([^{)}]*)' . "{$options['quote']}" .
462
            '/';
463
        // phpcs:enable
464
        $matches = [];
465
        preg_match_all($rgxp, $content, $matches);
466
467
        $limit = count($matches[0]);
468
        for ($i = 0; $i < $limit; $i++) {
469
            $str = $matches[2][$i];
470
            $pos = $this->strposX($str, ',', 2);
471
            $str = trim(substr($str, $pos + 1));
472
            if (strpos($str, ',') > 0) {
473
                $str = substr($str, 1, strpos($str, ',') - 2);
474
            } else {
475
                $str = substr($str, 1);
476
            }
477
            $item = $this->fixString($str);
478
            if (!in_array($item, $this->poResult)) {
479
                $this->poResult[] = $item;
480
            }
481
        }
482
    }
483
484
    /**
485
     * Calculate nth ($number) position of $needle in $haystack.
486
     *
487
     * @param string $haystack The haystack where to search
488
     * @param string $needle The needle to search
489
     * @param int $number The nth position to retrieve
490
     * @return int|false
491
     */
492
    private function strposX($haystack, $needle, $number = 0)
493
    {
494
        return strpos(
495
            $haystack,
496
            $needle,
497
            $number > 1 ?
498
            $this->strposX($haystack, $needle, $number - 1) + strlen($needle) : 0
499
        );
500
    }
501
502
    /**
503
     * Parse a directory
504
     *
505
     * @param string $dir The directory
506
     * @return void
507
     */
508
    private function parseDir($dir): void
509
    {
510
        $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

510
        $folder = /** @scrutinizer ignore-deprecated */ new Folder($dir);
Loading history...
511
        $tree = $folder->tree($dir, false);
512
        foreach ($tree as $files) {
513
            foreach ($files as $file) {
514
                if (!is_dir($file)) {
515
                    $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

515
                    $f = /** @scrutinizer ignore-deprecated */ new File($file);
Loading history...
516
                    $info = $f->info();
517
                    if (isset($info['extension'])) {
518
                        $this->parseFile($file, $info['extension']);
519
                    }
520
                }
521
            }
522
        }
523
    }
524
525
    /**
526
     * Extract translations from javascript files using ttag, if available.
527
     *
528
     * @return void
529
     * @codeCoverageIgnore
530
     */
531
    private function ttagExtract(): void
532
    {
533
        // check ttag command exists
534
        $ttag = 'node_modules/ttag-cli/bin/ttag';
535
        if (!file_exists($ttag)) {
536
            $this->out(sprintf('Skip javascript parsing - %s command not found', $ttag));
537
538
            return;
539
        }
540
        // check template folder exists
541
        $appDir = 'src/Template';
542
        if (!empty($this->params['plugin'])) {
543
            $startPath = !empty($this->params['startPath']) ? $this->params['startPath'] : getcwd();
544
            $appDir = sprintf('%s/plugins/%s/src/Template', $startPath, $this->params['plugin']);
545
        }
546
        if (!file_exists($appDir)) {
547
            $this->out(sprintf('Skip javascript parsing - %s folder not found', $appDir));
548
549
            return;
550
        }
551
552
        // do extract translation strings from js files using ttag
553
        $this->out('Extracting translation string from javascript files using ttag');
554
        $masterJs = sprintf('%s/master-js.pot', $this->localePath);
555
        exec(sprintf('%s extract --o %s --l en %s', $ttag, $masterJs, $appDir));
556
557
        // merge master-js.pot and master.pot
558
        $master = sprintf('%s/master.pot', $this->localePath);
559
        exec(sprintf('msgcat --use-first %s %s -o %s', $master, $masterJs, $master));
560
561
        // remove master-js.pot
562
        unlink($masterJs);
563
    }
564
}
565