MessageController::saveMessagesToDb()   F
last analyzed

Complexity

Conditions 29
Paths > 20000

Size

Total Lines 140
Code Lines 88

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 87
CRAP Score 29

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 29
eloc 88
c 1
b 1
f 0
nc 89856
nop 7
dl 0
loc 140
ccs 87
cts 87
cp 1
crap 29
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * @link https://www.yiiframework.com/
5
 * @copyright Copyright (c) 2008 Yii Software LLC
6
 * @license https://www.yiiframework.com/license/
7
 */
8
9
namespace yii\console\controllers;
10
11
use Yii;
12
use yii\console\Exception;
13
use yii\console\ExitCode;
14
use yii\db\Connection;
15
use yii\db\Query;
16
use yii\di\Instance;
17
use yii\helpers\Console;
18
use yii\helpers\FileHelper;
19
use yii\helpers\VarDumper;
20
use yii\i18n\GettextPoFile;
21
22
/**
23
 * Extracts messages to be translated from source files.
24
 *
25
 * The extracted messages can be saved the following depending on `format`
26
 * setting in config file:
27
 *
28
 * - PHP message source files.
29
 * - ".po" files.
30
 * - Database.
31
 *
32
 * Usage:
33
 * 1. Create a configuration file using the 'message/config' command:
34
 *    yii message/config /path/to/myapp/messages/config.php
35
 * 2. Edit the created config file, adjusting it for your web application needs.
36
 * 3. Run the 'message/extract' command, using created config:
37
 *    yii message /path/to/myapp/messages/config.php
38
 *
39
 * @author Qiang Xue <[email protected]>
40
 * @since 2.0
41
 */
42
class MessageController extends \yii\console\Controller
43
{
44
    /**
45
     * @var string controller default action ID.
46
     */
47
    public $defaultAction = 'extract';
48
    /**
49
     * @var string required, root directory of all source files.
50
     */
51
    public $sourcePath = '@yii';
52
    /**
53
     * @var string required, root directory containing message translations.
54
     */
55
    public $messagePath = '@yii/messages';
56
    /**
57
     * @var array required, list of language codes that the extracted messages
58
     * should be translated to. For example, ['zh-CN', 'de'].
59
     */
60
    public $languages = [];
61
    /**
62
     * @var string|string[] the name of the function for translating messages.
63
     * This is used as a mark to find the messages to be translated.
64
     * You may use a string for single function name or an array for multiple function names.
65
     */
66
    public $translator = ['Yii::t', '\Yii::t'];
67
    /**
68
     * @var bool whether to sort messages by keys when merging new messages
69
     * with the existing ones. Defaults to false, which means the new (untranslated)
70
     * messages will be separated from the old (translated) ones.
71
     */
72
    public $sort = false;
73
    /**
74
     * @var bool whether the message file should be overwritten with the merged messages
75
     */
76
    public $overwrite = true;
77
    /**
78
     * @var bool whether to remove messages that no longer appear in the source code.
79
     * Defaults to false, which means these messages will NOT be removed.
80
     */
81
    public $removeUnused = false;
82
    /**
83
     * @var bool whether to mark messages that no longer appear in the source code.
84
     * Defaults to true, which means each of these messages will be enclosed with a pair of '@@' marks.
85
     */
86
    public $markUnused = true;
87
    /**
88
     * @var array|null list of patterns that specify which files/directories should NOT be processed.
89
     * If empty or not set, all files/directories will be processed.
90
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
91
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
92
     */
93
    public $except = [
94
        '.*',
95
        '/.*',
96
        '/messages',
97
        '/tests',
98
        '/runtime',
99
        '/vendor',
100
        '/BaseYii.php', // contains examples about Yii::t()
101
    ];
102
    /**
103
     * @var array|null list of patterns that specify which files (not directories) should be processed.
104
     * If empty or not set, all files will be processed.
105
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
106
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
107
     */
108
    public $only = ['*.php'];
109
    /**
110
     * @var string generated file format. Can be "php", "db", "po" or "pot".
111
     */
112
    public $format = 'php';
113
    /**
114
     * @var string connection component ID for "db" format.
115
     */
116
    public $db = 'db';
117
    /**
118
     * @var string custom name for source message table for "db" format.
119
     */
120
    public $sourceMessageTable = '{{%source_message}}';
121
    /**
122
     * @var string custom name for translation message table for "db" format.
123
     */
124
    public $messageTable = '{{%message}}';
125
    /**
126
     * @var string name of the file that will be used for translations for "po" format.
127
     */
128
    public $catalog = 'messages';
129
    /**
130
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
131
     * @see isCategoryIgnored
132
     */
133
    public $ignoreCategories = [];
134
    /**
135
     * @var string File header in generated PHP file with messages. This property is used only if [[$format]] is "php".
136
     * @since 2.0.13
137
     */
138
    public $phpFileHeader = '';
139
    /**
140
     * @var string|null DocBlock used for messages array in generated PHP file. If `null`, default DocBlock will be used.
141
     * This property is used only if [[$format]] is "php".
142
     * @since 2.0.13
143
     */
144
    public $phpDocBlock;
145
146
    /**
147
     * @var array Config for messages extraction.
148
     * @see actionExtract()
149
     * @see initConfig()
150
     * @since 2.0.13
151
     */
152
    protected $config;
153
154
155
    /**
156
     * {@inheritdoc}
157
     */
158 69
    public function options($actionID)
159
    {
160 69
        return array_merge(parent::options($actionID), [
161 69
            'sourcePath',
162 69
            'messagePath',
163 69
            'languages',
164 69
            'translator',
165 69
            'sort',
166 69
            'overwrite',
167 69
            'removeUnused',
168 69
            'markUnused',
169 69
            'except',
170 69
            'only',
171 69
            'format',
172 69
            'db',
173 69
            'sourceMessageTable',
174 69
            'messageTable',
175 69
            'catalog',
176 69
            'ignoreCategories',
177 69
            'phpFileHeader',
178 69
            'phpDocBlock',
179 69
        ]);
180
    }
181
182
    /**
183
     * {@inheritdoc}
184
     * @since 2.0.8
185
     */
186
    public function optionAliases()
187
    {
188
        return array_merge(parent::optionAliases(), [
189
            'c' => 'catalog',
190
            'e' => 'except',
191
            'f' => 'format',
192
            'i' => 'ignoreCategories',
193
            'l' => 'languages',
194
            'u' => 'markUnused',
195
            'p' => 'messagePath',
196
            'o' => 'only',
197
            'w' => 'overwrite',
198
            'S' => 'sort',
199
            't' => 'translator',
200
            'm' => 'sourceMessageTable',
201
            's' => 'sourcePath',
202
            'r' => 'removeUnused',
203
        ]);
204
    }
205
206
    /**
207
     * Creates a configuration file for the "extract" command using command line options specified.
208
     *
209
     * The generated configuration file contains parameters required
210
     * for source code messages extraction.
211
     * You may use this configuration file with the "extract" command.
212
     *
213
     * @param string $filePath output file name or alias.
214
     * @return int CLI exit code
215
     * @throws Exception on failure.
216
     */
217 6
    public function actionConfig($filePath)
218
    {
219 6
        $filePath = Yii::getAlias($filePath);
220 6
        $dir = dirname($filePath);
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $path of dirname() 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

220
        $dir = dirname(/** @scrutinizer ignore-type */ $filePath);
Loading history...
221
222 6
        if (file_exists($filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; 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

222
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
223
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
224
                return ExitCode::OK;
225
            }
226
        }
227
228 6
        $array = VarDumper::export($this->getOptionValues($this->action->id));
229 6
        $content = <<<EOD
230 6
<?php
231
/**
232 6
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
233
 *
234 6
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
235
 * It contains parameters for source code messages extraction.
236
 * You may modify this file to suit your needs.
237
 *
238 6
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
239
 * template configuration file with detailed description for each parameter.
240
 */
241 6
return $array;
242
243 6
EOD;
244
245 6
        if (FileHelper::createDirectory($dir) === false || file_put_contents($filePath, $content, LOCK_EX) === false) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_put_contents() 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

245
        if (FileHelper::createDirectory($dir) === false || file_put_contents(/** @scrutinizer ignore-type */ $filePath, $content, LOCK_EX) === false) {
Loading history...
246
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
247
            return ExitCode::UNSPECIFIED_ERROR;
248
        }
249
250 6
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
251 6
        return ExitCode::OK;
252
    }
253
254
    /**
255
     * Creates a configuration file template for the "extract" command.
256
     *
257
     * The created configuration file contains detailed instructions on
258
     * how to customize it to fit for your needs. After customization,
259
     * you may use this configuration file with the "extract" command.
260
     *
261
     * @param string $filePath output file name or alias.
262
     * @return int CLI exit code
263
     * @throws Exception on failure.
264
     */
265
    public function actionConfigTemplate($filePath)
266
    {
267
        $filePath = Yii::getAlias($filePath);
268
269
        if (file_exists($filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; 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

269
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
270
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
271
                return ExitCode::OK;
272
            }
273
        }
274
275
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $to of copy() 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

275
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), /** @scrutinizer ignore-type */ $filePath)) {
Loading history...
Bug introduced by
It seems like Yii::getAlias('@yii/views/messageConfig.php') can also be of type false; however, parameter $from of copy() 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

275
        if (!copy(/** @scrutinizer ignore-type */ Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
Loading history...
276
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
277
            return ExitCode::UNSPECIFIED_ERROR;
278
        }
279
280
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
281
        return ExitCode::OK;
282
    }
283
284
    /**
285
     * Extracts messages to be translated from source code.
286
     *
287
     * This command will search through source code files and extract
288
     * messages that need to be translated in different languages.
289
     *
290
     * @param string|null $configFile the path or alias of the configuration file.
291
     * You may use the "yii message/config" command to generate
292
     * this file and then customize it for your needs.
293
     * @throws Exception on failure.
294
     */
295 63
    public function actionExtract($configFile = null)
296
    {
297 63
        $this->initConfig($configFile);
298
299 60
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
300
301 60
        $messages = [];
302 60
        foreach ($files as $file) {
303 54
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
304
        }
305
306 60
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
307
308 60
        if (in_array($this->config['format'], ['php', 'po'])) {
309 45
            foreach ($this->config['languages'] as $language) {
310 45
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
311 45
                if (!is_dir($dir) && !@mkdir($dir)) {
312
                    throw new Exception("Directory '{$dir}' can not be created.");
313
                }
314 45
                if ($this->config['format'] === 'po') {
315 16
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
316
                } else {
317 29
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
318
                }
319
            }
320 15
        } elseif ($this->config['format'] === 'db') {
321
            /** @var Connection $db */
322 15
            $db = Instance::ensure($this->config['db'], Connection::className());
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

322
            $db = Instance::ensure($this->config['db'], /** @scrutinizer ignore-deprecated */ Connection::className());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
323 15
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
324 15
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
325 15
            $this->saveMessagesToDb(
326 15
                $messages,
327 15
                $db,
328 15
                $sourceMessageTable,
329 15
                $messageTable,
330 15
                $this->config['removeUnused'],
331 15
                $this->config['languages'],
332 15
                $this->config['markUnused']
333 15
            );
334
        } elseif ($this->config['format'] === 'pot') {
335
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
336
        }
337
    }
338
339
    /**
340
     * Saves messages to database.
341
     *
342
     * @param array $messages
343
     * @param Connection $db
344
     * @param string $sourceMessageTable
345
     * @param string $messageTable
346
     * @param bool $removeUnused
347
     * @param array $languages
348
     * @param bool $markUnused
349
     */
350 15
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
351
    {
352 15
        $currentMessages = [];
353 15
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
354 15
        foreach ($rows as $row) {
355 14
            $currentMessages[$row['category']][$row['id']] = $row['message'];
356
        }
357
358 15
        $new = [];
359 15
        $obsolete = [];
360
361 15
        foreach ($messages as $category => $msgs) {
362 15
            $msgs = array_unique($msgs);
363
364 15
            if (isset($currentMessages[$category])) {
365 10
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
366
                // obsolete messages per category
367 10
                $obsolete += array_diff($currentMessages[$category], $msgs);
368
            } else {
369 8
                $new[$category] = $msgs;
370
            }
371
        }
372
373
        // obsolete categories
374 15
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
375 9
            $obsolete += $currentMessages[$category];
376
        }
377
378 15
        if (!$removeUnused) {
379 14
            foreach ($obsolete as $pk => $msg) {
380
                // skip already marked unused
381 11
                if (strncmp($msg, '@@', 2) === 0 && substr($msg, -2) === '@@') {
382 6
                    unset($obsolete[$pk]);
383
                }
384
            }
385
        }
386
387 15
        $this->stdout('Inserting new messages...');
388 15
        $insertCount = 0;
389
390 15
        foreach ($new as $category => $msgs) {
391 15
            foreach ($msgs as $msg) {
392 13
                $insertCount++;
393 13
                $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
394
            }
395
        }
396
397 15
        $this->stdout($insertCount ? "{$insertCount} saved.\n" : "Nothing to save.\n");
398
399 15
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
400
401 15
        if (empty($obsolete)) {
402 6
            $this->stdout("Nothing obsoleted...skipped.\n");
403
        }
404
405 15
        if ($obsolete) {
406 12
            if ($removeUnused) {
407 1
                $affected = $db->createCommand()
408 1
                   ->delete($sourceMessageTable, ['in', 'id', array_keys($obsolete)])
409 1
                   ->execute();
410 1
                $this->stdout("{$affected} deleted.\n");
411 11
            } elseif ($markUnused) {
412 9
                $marked = 0;
413 9
                $rows = (new Query())
414 9
                    ->select(['id', 'message'])
415 9
                    ->from($sourceMessageTable)
416 9
                    ->where(['in', 'id', array_keys($obsolete)])
417 9
                    ->all($db);
418
419 9
                foreach ($rows as $row) {
420 9
                    $marked++;
421 9
                    $db->createCommand()->update(
422 9
                        $sourceMessageTable,
423 9
                        ['message' => '@@' . $row['message'] . '@@'],
424 9
                        ['id' => $row['id']]
425 9
                    )->execute();
426
                }
427 9
                $this->stdout("{$marked} updated.\n");
428
            } else {
429 2
                $this->stdout("kept untouched.\n");
430
            }
431
        }
432
433
        // get fresh message id list
434 15
        $freshMessagesIds = [];
435 15
        $rows = (new Query())->select(['id'])->from($sourceMessageTable)->all($db);
436 15
        foreach ($rows as $row) {
437 15
            $freshMessagesIds[] = $row['id'];
438
        }
439
440 15
        $this->stdout('Generating missing rows...');
441 15
        $generatedMissingRows = [];
442
443 15
        foreach ($languages as $language) {
444 15
            $count = 0;
445
446
            // get list of ids of translations for this language
447 15
            $msgRowsIds = [];
448 15
            $msgRows = (new Query())->select(['id'])->from($messageTable)->where([
449 15
                'language' => $language,
450 15
            ])->all($db);
451 15
            foreach ($msgRows as $row) {
452 14
                $msgRowsIds[] = $row['id'];
453
            }
454
455
            // insert missing
456 15
            foreach ($freshMessagesIds as $id) {
457 15
                if (!in_array($id, $msgRowsIds)) {
458 13
                    $db->createCommand()
459 13
                       ->insert($messageTable, ['id' => $id, 'language' => $language])
460 13
                       ->execute();
461 13
                    $count++;
462
                }
463
            }
464 15
            if ($count) {
465 13
                $generatedMissingRows[] = "{$count} for {$language}";
466
            }
467
        }
468
469 15
        $this->stdout($generatedMissingRows ? implode(', ', $generatedMissingRows) . ".\n" : "Nothing to do.\n");
470
471 15
        $this->stdout('Dropping unused languages...');
472 15
        $droppedLanguages = [];
473
474 15
        $currentLanguages = [];
475 15
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
476 15
        foreach ($rows as $row) {
477 15
            $currentLanguages[] = $row['language'];
478
        }
479
480 15
        foreach ($currentLanguages as $currentLanguage) {
481 15
            if (!in_array($currentLanguage, $languages)) {
482 1
                $deleted = $db->createCommand()->delete($messageTable, 'language=:language', [
483 1
                    'language' => $currentLanguage,
484 1
                ])->execute();
485 1
                $droppedLanguages[] = "removed {$deleted} rows for $currentLanguage";
486
            }
487
        }
488
489 15
        $this->stdout($droppedLanguages ? implode(', ', $droppedLanguages) . ".\n" : "Nothing to do.\n");
490
    }
491
492
    /**
493
     * Extracts messages from a file.
494
     *
495
     * @param string $fileName name of the file to extract messages from
496
     * @param string $translator name of the function used to translate messages
497
     * @param array $ignoreCategories message categories to ignore.
498
     * This parameter is available since version 2.0.4.
499
     * @return array
500
     */
501 54
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
502
    {
503 54
        $this->stdout('Extracting messages from ');
504 54
        $this->stdout($fileName, Console::FG_CYAN);
505 54
        $this->stdout("...\n");
506
507 54
        $subject = file_get_contents($fileName);
508 54
        $messages = [];
509 54
        $tokens = token_get_all($subject);
510 54
        foreach ((array) $translator as $currentTranslator) {
511 54
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
512 54
            array_shift($translatorTokens);
513 54
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
514
        }
515
516 54
        $this->stdout("\n");
517
518 54
        return $messages;
519
    }
520
521
    /**
522
     * Extracts messages from a parsed PHP tokens list.
523
     * @param array $tokens tokens to be processed.
524
     * @param array $translatorTokens translator tokens.
525
     * @param array $ignoreCategories message categories to ignore.
526
     * @return array messages.
527
     */
528 54
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
529
    {
530 54
        $messages = [];
531 54
        $translatorTokensCount = count($translatorTokens);
532 54
        $matchedTokensCount = 0;
533 54
        $buffer = [];
534 54
        $pendingParenthesisCount = 0;
535
536 54
        foreach ($tokens as $tokenIndex => $token) {
537
            // finding out translator call
538 54
            if ($matchedTokensCount < $translatorTokensCount) {
539 54
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
540 54
                    $matchedTokensCount++;
541
                } else {
542 54
                    $matchedTokensCount = 0;
543
                }
544 54
            } elseif ($matchedTokensCount === $translatorTokensCount) {
545
                // translator found
546
547
                // end of function call
548 54
                if ($this->tokensEqual(')', $token)) {
549 54
                    $pendingParenthesisCount--;
550
551 54
                    if ($pendingParenthesisCount === 0) {
552
                        // end of translator call or end of something that we can't extract
553 54
                        if (isset($buffer[0][0], $buffer[1], $buffer[2][0]) && $buffer[0][0] === T_CONSTANT_ENCAPSED_STRING && $buffer[1] === ',' && $buffer[2][0] === T_CONSTANT_ENCAPSED_STRING) {
554
                            // is valid call we can extract
555 54
                            $category = stripcslashes($buffer[0][1]);
556 54
                            $category = mb_substr($category, 1, -1);
557
558 54
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
559 54
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
560 54
                                $i = 3;
561 54
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
562 3
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
563 3
                                    $i += 2;
564
                                }
565
566 54
                                $message = stripcslashes($fullMessage);
567 54
                                $messages[$category][] = $message;
568
                            }
569
570 54
                            $nestedTokens = array_slice($buffer, 3);
571 54
                            if (count($nestedTokens) > $translatorTokensCount) {
572
                                // search for possible nested translator calls
573 54
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
574
                            }
575
                        } else {
576
                            // invalid call or dynamic call we can't extract
577
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
578
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
579
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
580
                        }
581
582
                        // prepare for the next match
583 54
                        $matchedTokensCount = 0;
584 54
                        $pendingParenthesisCount = 0;
585 54
                        $buffer = [];
586
                    } else {
587 54
                        $buffer[] = $token;
588
                    }
589 54
                } elseif ($this->tokensEqual('(', $token)) {
590
                    // count beginning of function call, skipping translator beginning
591
592
                    // If we are not yet inside the translator, make sure that it's beginning of the real translator.
593
                    // See https://github.com/yiisoft/yii2/issues/16828
594 54
                    if ($pendingParenthesisCount === 0) {
595 54
                        $previousTokenIndex = $tokenIndex - $matchedTokensCount - 1;
596 54
                        if (is_array($tokens[$previousTokenIndex])) {
597 54
                            $previousToken = $tokens[$previousTokenIndex][0];
598 54
                            if (in_array($previousToken, [T_OBJECT_OPERATOR, T_PAAMAYIM_NEKUDOTAYIM], true)) {
599 3
                                $matchedTokensCount = 0;
600 3
                                continue;
601
                            }
602
                        }
603
                    }
604
605 54
                    if ($pendingParenthesisCount > 0) {
606 6
                        $buffer[] = $token;
607
                    }
608 54
                    $pendingParenthesisCount++;
609 54
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
610
                    // ignore comments and whitespaces
611 54
                    $buffer[] = $token;
612
                }
613
            }
614
        }
615
616 54
        return $messages;
617
    }
618
619
    /**
620
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
621
     *
622
     * Examples:
623
     *
624
     * - `myapp` - will be ignored only `myapp` category;
625
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
626
     *
627
     * @param string $category category that is checked
628
     * @param array $ignoreCategories message categories to ignore.
629
     * @return bool
630
     * @since 2.0.7
631
     */
632 54
    protected function isCategoryIgnored($category, array $ignoreCategories)
633
    {
634 54
        if (!empty($ignoreCategories)) {
635 3
            if (in_array($category, $ignoreCategories, true)) {
636 3
                return true;
637
            }
638 3
            foreach ($ignoreCategories as $pattern) {
639 3
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
640 3
                    return true;
641
                }
642
            }
643
        }
644
645 54
        return false;
646
    }
647
648
    /**
649
     * Finds out if two PHP tokens are equal.
650
     *
651
     * @param array|string $a
652
     * @param array|string $b
653
     * @return bool
654
     * @since 2.0.1
655
     */
656 54
    protected function tokensEqual($a, $b)
657
    {
658 54
        if (is_string($a) && is_string($b)) {
659 54
            return $a === $b;
660
        }
661 54
        if (isset($a[0], $a[1], $b[0], $b[1])) {
662 54
            return $a[0] === $b[0] && $a[1] == $b[1];
663
        }
664
665 54
        return false;
666
    }
667
668
    /**
669
     * Finds out a line of the first non-char PHP token found.
670
     *
671
     * @param array $tokens
672
     * @return int|string
673
     * @since 2.0.1
674
     */
675
    protected function getLine($tokens)
676
    {
677
        foreach ($tokens as $token) {
678
            if (isset($token[2])) {
679
                return $token[2];
680
            }
681
        }
682
683
        return 'unknown';
684
    }
685
686
    /**
687
     * Writes messages into PHP files.
688
     *
689
     * @param array $messages
690
     * @param string $dirName name of the directory to write to
691
     * @param bool $overwrite if existing file should be overwritten without backup
692
     * @param bool $removeUnused if obsolete translations should be removed
693
     * @param bool $sort if translations should be sorted
694
     * @param bool $markUnused if obsolete translations should be marked
695
     */
696 29
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
697
    {
698 29
        foreach ($messages as $category => $msgs) {
699 23
            $file = str_replace('\\', '/', "$dirName/$category.php");
700 23
            $path = dirname($file);
701 23
            FileHelper::createDirectory($path);
702 23
            $msgs = array_values(array_unique($msgs));
703 23
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
704 23
            $this->stdout("Saving messages to $coloredFileName...\n");
705 23
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
706
        }
707
708 29
        if ($removeUnused) {
709 7
            $this->deleteUnusedPhpMessageFiles(array_keys($messages), $dirName);
710
        }
711
    }
712
713
    /**
714
     * Writes category messages into PHP file.
715
     *
716
     * @param array $messages
717
     * @param string $fileName name of the file to write to
718
     * @param bool $overwrite if existing file should be overwritten without backup
719
     * @param bool $removeUnused if obsolete translations should be removed
720
     * @param bool $sort if translations should be sorted
721
     * @param string $category message category
722
     * @param bool $markUnused if obsolete translations should be marked
723
     * @return int exit code
724
     */
725 23
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
726
    {
727 23
        if (is_file($fileName)) {
728 16
            $rawExistingMessages = require $fileName;
729 16
            $existingMessages = $rawExistingMessages;
730 16
            sort($messages);
731 16
            ksort($existingMessages);
732 16
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
733 10
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
734 10
                return ExitCode::OK;
735
            }
736 7
            unset($rawExistingMessages);
737 7
            $merged = [];
738 7
            $untranslated = [];
739 7
            foreach ($messages as $message) {
740 7
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
741 4
                    $merged[$message] = $existingMessages[$message];
742
                } else {
743 6
                    $untranslated[] = $message;
744
                }
745
            }
746 7
            ksort($merged);
747 7
            sort($untranslated);
748 7
            $todo = [];
749 7
            foreach ($untranslated as $message) {
750 6
                $todo[$message] = '';
751
            }
752 7
            ksort($existingMessages);
753 7
            foreach ($existingMessages as $message => $translation) {
754 7
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
755 3
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
756 2
                        $todo[$message] = $translation;
757
                    } else {
758 1
                        $todo[$message] = '@@' . $translation . '@@';
759
                    }
760
                }
761
            }
762 7
            $merged = array_merge($merged, $todo);
763 7
            if ($sort) {
764 1
                ksort($merged);
765
            }
766 7
            if (false === $overwrite) {
767
                $fileName .= '.merged';
768
            }
769 7
            $this->stdout("Translation merged.\n");
770
        } else {
771 11
            $merged = [];
772 11
            foreach ($messages as $message) {
773 11
                $merged[$message] = '';
774
            }
775 11
            ksort($merged);
776
        }
777
778 17
        $array = VarDumper::export($merged);
779 17
        $content = <<<EOD
780 17
<?php
781 17
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
782 17
return $array;
783
784 17
EOD;
785
786 17
        if (file_put_contents($fileName, $content, LOCK_EX) === false) {
787
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
788
            return ExitCode::UNSPECIFIED_ERROR;
789
        }
790
791 17
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
792 17
        return ExitCode::OK;
793
    }
794
795
    /**
796
     * Writes messages into PO file.
797
     *
798
     * @param array $messages
799
     * @param string $dirName name of the directory to write to
800
     * @param bool $overwrite if existing file should be overwritten without backup
801
     * @param bool $removeUnused if obsolete translations should be removed
802
     * @param bool $sort if translations should be sorted
803
     * @param string $catalog message catalog
804
     * @param bool $markUnused if obsolete translations should be marked
805
     */
806 16
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
807
    {
808 16
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
809 16
        FileHelper::createDirectory(dirname($file));
810 16
        $this->stdout("Saving messages to $file...\n");
811
812 16
        $poFile = new GettextPoFile();
813
814 16
        $merged = [];
815 16
        $todos = [];
816
817 16
        $hasSomethingToWrite = false;
818 16
        foreach ($messages as $category => $msgs) {
819 16
            $notTranslatedYet = [];
820 16
            $msgs = array_values(array_unique($msgs));
821
822 16
            if (is_file($file)) {
823 10
                $existingMessages = $poFile->load($file, $category);
824
825 10
                sort($msgs);
826 10
                ksort($existingMessages);
827 10
                if (array_keys($existingMessages) == $msgs) {
828 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
829
830 4
                    sort($msgs);
831 4
                    foreach ($msgs as $message) {
832 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
833
                    }
834 4
                    ksort($merged);
835 4
                    continue;
836
                }
837
838
                // merge existing message translations with new message translations
839 7
                foreach ($msgs as $message) {
840 7
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
841 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
842
                    } else {
843 6
                        $notTranslatedYet[] = $message;
844
                    }
845
                }
846 7
                ksort($merged);
847 7
                sort($notTranslatedYet);
848
849
                // collect not yet translated messages
850 7
                foreach ($notTranslatedYet as $message) {
851 6
                    $todos[$category . chr(4) . $message] = '';
852
                }
853
854
                // add obsolete unused messages
855 7
                foreach ($existingMessages as $message => $translation) {
856 7
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
857 3
                        if (!$markUnused || (!empty($translation) && (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
858 2
                            $todos[$category . chr(4) . $message] = $translation;
859
                        } else {
860 1
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
861
                        }
862
                    }
863
                }
864
865 7
                $merged = array_merge($merged, $todos);
866 7
                if ($sort) {
867 1
                    ksort($merged);
868
                }
869
870 7
                if ($overwrite === false) {
871 7
                    $file .= '.merged';
872
                }
873
            } else {
874 10
                sort($msgs);
875 10
                foreach ($msgs as $message) {
876 10
                    $merged[$category . chr(4) . $message] = '';
877
                }
878 10
                ksort($merged);
879
            }
880 16
            $this->stdout("Category \"$category\" merged.\n");
881 16
            $hasSomethingToWrite = true;
882
        }
883 16
        if ($hasSomethingToWrite) {
884 16
            $poFile->save($file, $merged);
885 16
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
886
        } else {
887 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
888
        }
889
    }
890
891
    /**
892
     * Writes messages into POT file.
893
     *
894
     * @param array $messages
895
     * @param string $dirName name of the directory to write to
896
     * @param string $catalog message catalog
897
     * @since 2.0.6
898
     */
899
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
900
    {
901
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
902
        FileHelper::createDirectory(dirname($file));
903
        $this->stdout("Saving messages to $file...\n");
904
905
        $poFile = new GettextPoFile();
906
907
        $merged = [];
908
909
        $hasSomethingToWrite = false;
910
        foreach ($messages as $category => $msgs) {
911
            $msgs = array_values(array_unique($msgs));
912
913
            sort($msgs);
914
            foreach ($msgs as $message) {
915
                $merged[$category . chr(4) . $message] = '';
916
            }
917
            $this->stdout("Category \"$category\" merged.\n");
918
            $hasSomethingToWrite = true;
919
        }
920
        if ($hasSomethingToWrite) {
921
            ksort($merged);
922
            $poFile->save($file, $merged);
923
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
924
        } else {
925
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
926
        }
927
    }
928
929 7
    private function deleteUnusedPhpMessageFiles($existingCategories, $dirName)
930
    {
931 7
        $messageFiles = FileHelper::findFiles($dirName);
932 7
        foreach ($messageFiles as $messageFile) {
933 7
            $categoryFileName = str_replace($dirName, '', $messageFile);
934 7
            $categoryFileName = ltrim($categoryFileName, DIRECTORY_SEPARATOR);
935 7
            $category = preg_replace('#\.php$#', '', $categoryFileName);
936 7
            $category = str_replace(DIRECTORY_SEPARATOR, '/', $category);
937
938 7
            if (!in_array($category, $existingCategories, true)) {
939 3
                unlink($messageFile);
940
            }
941
        }
942
    }
943
944
    /**
945
     * @param string $configFile
946
     * @throws Exception If configuration file does not exists.
947
     * @since 2.0.13
948
     */
949 63
    protected function initConfig($configFile)
950
    {
951 63
        $configFileContent = [];
952 63
        if ($configFile !== null) {
0 ignored issues
show
introduced by
The condition $configFile !== null is always true.
Loading history...
953 63
            $configFile = Yii::getAlias($configFile);
954 63
            if (!is_file($configFile)) {
0 ignored issues
show
Bug introduced by
It seems like $configFile can also be of type false; however, parameter $filename of is_file() 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

954
            if (!is_file(/** @scrutinizer ignore-type */ $configFile)) {
Loading history...
955 3
                throw new Exception("The configuration file does not exist: $configFile");
956
            }
957 60
            $configFileContent = require $configFile;
958
        }
959
960 60
        $this->config = array_merge(
961 60
            $this->getOptionValues($this->action->id),
962 60
            $configFileContent,
963 60
            $this->getPassedOptionValues()
964 60
        );
965 60
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
966 60
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
967
968 60
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
969
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
970
        }
971 60
        if (!is_dir($this->config['sourcePath'])) {
0 ignored issues
show
Bug introduced by
It seems like $this->config['sourcePath'] can also be of type false; however, parameter $filename of is_dir() 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

971
        if (!is_dir(/** @scrutinizer ignore-type */ $this->config['sourcePath'])) {
Loading history...
972
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
973
        }
974 60
        if (empty($this->config['format']) || !in_array($this->config['format'], ['php', 'po', 'pot', 'db'])) {
975
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
976
        }
977 60
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
978 45
            if (!isset($this->config['messagePath'])) {
979
                throw new Exception('The configuration file must specify "messagePath".');
980
            }
981 45
            if (!is_dir($this->config['messagePath'])) {
982
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
983
            }
984
        }
985 60
        if (empty($this->config['languages'])) {
986
            throw new Exception('Languages cannot be empty.');
987
        }
988
989 60
        if ($this->config['format'] === 'php' && $this->config['phpDocBlock'] === null) {
990
            $this->config['phpDocBlock'] = <<<DOCBLOCK
991
/**
992
 * Message translations.
993
 *
994
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
995
 * It contains the localizable messages extracted from source code.
996
 * You may modify this file by translating the extracted messages.
997
 *
998
 * Each array element represents the translation (value) of a message (key).
999
 * If the value is empty, the message is considered as not translated.
1000
 * Messages that no longer need translation will have their translations
1001
 * enclosed between a pair of '@@' marks.
1002
 *
1003
 * Message string can be used with plural forms format. Check i18n section
1004
 * of the guide for details.
1005
 *
1006
 * NOTE: this file must be saved in UTF-8 encoding.
1007
 */
1008
DOCBLOCK;
1009
        }
1010
    }
1011
}
1012