Completed
Push — fix-error-handling-regression ( ef2bf2 )
by Alexander
09:24
created

MessageController::extractMessagesFromTokens()   C

Complexity

Conditions 20
Paths 14

Size

Total Lines 77
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 20.136

Importance

Changes 0
Metric Value
dl 0
loc 77
ccs 40
cts 43
cp 0.9302
rs 5.1617
c 0
b 0
f 0
cc 20
eloc 46
nc 14
nop 3
crap 20.136

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
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\console\controllers;
9
10
use Yii;
11
use yii\console\Exception;
12
use yii\db\Connection;
13
use yii\db\Query;
14
use yii\di\Instance;
15
use yii\helpers\Console;
16
use yii\helpers\FileHelper;
17
use yii\helpers\VarDumper;
18
use yii\i18n\GettextPoFile;
19
20
/**
21
 * Extracts messages to be translated from source files.
22
 *
23
 * The extracted messages can be saved the following depending on `format`
24
 * setting in config file:
25
 *
26
 * - PHP message source files.
27
 * - ".po" files.
28
 * - Database.
29
 *
30
 * Usage:
31
 * 1. Create a configuration file using the 'message/config' command:
32
 *    yii message/config /path/to/myapp/messages/config.php
33
 * 2. Edit the created config file, adjusting it for your web application needs.
34
 * 3. Run the 'message/extract' command, using created config:
35
 *    yii message /path/to/myapp/messages/config.php
36
 *
37
 * @author Qiang Xue <[email protected]>
38
 * @since 2.0
39
 */
40
class MessageController extends \yii\console\Controller
41
{
42
    /**
43
     * @var string controller default action ID.
44
     */
45
    public $defaultAction = 'extract';
46
    /**
47
     * @var string required, root directory of all source files.
48
     */
49
    public $sourcePath = '@yii';
50
    /**
51
     * @var string required, root directory containing message translations.
52
     */
53
    public $messagePath = '@yii/messages';
54
    /**
55
     * @var array required, list of language codes that the extracted messages
56
     * should be translated to. For example, ['zh-CN', 'de'].
57
     */
58
    public $languages = [];
59
    /**
60
     * @var string the name of the function for translating messages.
61
     * Defaults to 'Yii::t'. This is used as a mark to find the messages to be
62
     * translated. You may use a string for single function name or an array for
63
     * multiple function names.
64
     */
65
    public $translator = 'Yii::t';
66
    /**
67
     * @var bool whether to sort messages by keys when merging new messages
68
     * with the existing ones. Defaults to false, which means the new (untranslated)
69
     * messages will be separated from the old (translated) ones.
70
     */
71
    public $sort = false;
72
    /**
73
     * @var bool whether the message file should be overwritten with the merged messages
74
     */
75
    public $overwrite = true;
76
    /**
77
     * @var bool whether to remove messages that no longer appear in the source code.
78
     * Defaults to false, which means these messages will NOT be removed.
79
     */
80
    public $removeUnused = false;
81
    /**
82
     * @var bool whether to mark messages that no longer appear in the source code.
83
     * Defaults to true, which means each of these messages will be enclosed with a pair of '@@' marks.
84
     */
85
    public $markUnused = true;
86
    /**
87
     * @var array list of patterns that specify which files/directories should NOT be processed.
88
     * If empty or not set, all files/directories will be processed.
89
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
90
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
91
     */
92
    public $except = [
93
        '.svn',
94
        '.git',
95
        '.gitignore',
96
        '.gitkeep',
97
        '.hgignore',
98
        '.hgkeep',
99
        '/messages',
100
        '/BaseYii.php', // contains examples about Yii:t()
101
    ];
102
    /**
103
     * @var array 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
136
    /**
137
     * @inheritdoc
138
     */
139 38
    public function options($actionID)
140
    {
141 38
        return array_merge(parent::options($actionID), [
142 38
            'sourcePath',
143
            'messagePath',
144
            'languages',
145
            'translator',
146
            'sort',
147
            'overwrite',
148
            'removeUnused',
149
            'markUnused',
150
            'except',
151
            'only',
152
            'format',
153
            'db',
154
            'sourceMessageTable',
155
            'messageTable',
156
            'catalog',
157
            'ignoreCategories',
158
        ]);
159
    }
160
161
    /**
162
     * @inheritdoc
163
     * @since 2.0.8
164
     */
165
    public function optionAliases()
166
    {
167
        return array_merge(parent::optionAliases(), [
168
            'c' => 'catalog',
169
            'e' => 'except',
170
            'f' => 'format',
171
            'i' => 'ignoreCategories',
172
            'l' => 'languages',
173
            'u' => 'markUnused',
174
            'p' => 'messagePath',
175
            'o' => 'only',
176
            'w' => 'overwrite',
177
            'S' => 'sort',
178
            't' => 'translator',
179
            'm' => 'sourceMessageTable',
180
            's' => 'sourcePath',
181
            'r' => 'removeUnused',
182
        ]);
183
    }
184
185
    /**
186
     * Creates a configuration file for the "extract" command using command line options specified
187
     *
188
     * The generated configuration file contains parameters required
189
     * for source code messages extraction.
190
     * You may use this configuration file with the "extract" command.
191
     *
192
     * @param string $filePath output file name or alias.
193
     * @return int CLI exit code
194
     * @throws Exception on failure.
195
     */
196 3
    public function actionConfig($filePath)
197
    {
198 3
        $filePath = Yii::getAlias($filePath);
199 3
        if (file_exists($filePath)) {
200
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
201
                return self::EXIT_CODE_NORMAL;
202
            }
203
        }
204
205 3
        $array = VarDumper::export($this->getOptionValues($this->action->id));
206
        $content = <<<EOD
207
<?php
208
/**
209 3
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
210
 *
211 3
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
212
 * It contains parameters for source code messages extraction.
213
 * You may modify this file to suit your needs.
214
 *
215 3
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
216
 * template configuration file with detailed description for each parameter.
217
 */
218 3
return $array;
219
220
EOD;
221
222 3
        if (file_put_contents($filePath, $content) === false) {
223
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
224
            return self::EXIT_CODE_ERROR;
225
        }
226
227 3
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
228 3
        return self::EXIT_CODE_NORMAL;
229
    }
230
231
    /**
232
     * Creates a configuration file template for the "extract" command.
233
     *
234
     * The created configuration file contains detailed instructions on
235
     * how to customize it to fit for your needs. After customization,
236
     * you may use this configuration file with the "extract" command.
237
     *
238
     * @param string $filePath output file name or alias.
239
     * @return int CLI exit code
240
     * @throws Exception on failure.
241
     */
242
    public function actionConfigTemplate($filePath)
243
    {
244
        $filePath = Yii::getAlias($filePath);
245
246
        if (file_exists($filePath)) {
247
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
248
                return self::EXIT_CODE_NORMAL;
249
            }
250
        }
251
252
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
253
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
254
            return self::EXIT_CODE_ERROR;
255
        }
256
257
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
258
        return self::EXIT_CODE_NORMAL;
259
    }
260
261
    /**
262
     * Extracts messages to be translated from source code.
263
     *
264
     * This command will search through source code files and extract
265
     * messages that need to be translated in different languages.
266
     *
267
     * @param string $configFile the path or alias of the configuration file.
268
     * You may use the "yii message/config" command to generate
269
     * this file and then customize it for your needs.
270
     * @throws Exception on failure.
271
     */
272 35
    public function actionExtract($configFile = null)
273
    {
274 35
        $configFileContent = [];
275 35
        if ($configFile !== null) {
276 35
            $configFile = Yii::getAlias($configFile);
277 35
            if (!is_file($configFile)) {
278 3
                throw new Exception("The configuration file does not exist: $configFile");
279
            }
280 32
            $configFileContent = require($configFile);
281
        }
282
283 32
        $config = array_merge(
284 32
            $this->getOptionValues($this->action->id),
285 32
            $configFileContent,
286 32
            $this->getPassedOptionValues()
287
        );
288 32
        $config['sourcePath'] = Yii::getAlias($config['sourcePath']);
289 32
        $config['messagePath'] = Yii::getAlias($config['messagePath']);
290
291 32
        if (!isset($config['sourcePath'], $config['languages'])) {
292
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
293
        }
294 32
        if (!is_dir($config['sourcePath'])) {
295
            throw new Exception("The source path {$config['sourcePath']} is not a valid directory.");
296
        }
297 32
        if (empty($config['format']) || !in_array($config['format'], ['php', 'po', 'pot', 'db'])) {
298
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
299
        }
300 32
        if (in_array($config['format'], ['php', 'po', 'pot'])) {
301 22
            if (!isset($config['messagePath'])) {
302
                throw new Exception('The configuration file must specify "messagePath".');
303
            }
304 22
            if (!is_dir($config['messagePath'])) {
305
                throw new Exception("The message path {$config['messagePath']} is not a valid directory.");
306
            }
307
        }
308 32
        if (empty($config['languages'])) {
309
            throw new Exception('Languages cannot be empty.');
310
        }
311
312 32
        $files = FileHelper::findFiles(realpath($config['sourcePath']), $config);
313
314 32
        $messages = [];
315 32
        foreach ($files as $file) {
316 32
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $config['translator'], $config['ignoreCategories']));
317
        }
318
319 32
        $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages';
320
321 32
        if (in_array($config['format'], ['php', 'po'])) {
322 22
            foreach ($config['languages'] as $language) {
323 22
                $dir = $config['messagePath'] . DIRECTORY_SEPARATOR . $language;
324 22
                if (!is_dir($dir) && !@mkdir($dir)) {
325
                    throw new Exception("Directory '{$dir}' can not be created.");
326
                }
327 22
                if ($config['format'] === 'po') {
328 11
                    $this->saveMessagesToPO($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $catalog, $config['markUnused']);
329
                } else {
330 22
                    $this->saveMessagesToPHP($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $config['markUnused']);
331
                }
332
            }
333 10
        } elseif ($config['format'] === 'db') {
334
            /** @var Connection $db */
335 10
            $db = Instance::ensure($config['db'], Connection::className());
336 10
            $sourceMessageTable = isset($config['sourceMessageTable']) ? $config['sourceMessageTable'] : '{{%source_message}}';
337 10
            $messageTable = isset($config['messageTable']) ? $config['messageTable'] : '{{%message}}';
338 10
            $this->saveMessagesToDb(
339 10
                $messages,
340 10
                $db,
341 10
                $sourceMessageTable,
342 10
                $messageTable,
343 10
                $config['removeUnused'],
344 10
                $config['languages'],
345 10
                $config['markUnused']
346
            );
347
        } elseif ($config['format'] === 'pot') {
348
            $this->saveMessagesToPOT($messages, $config['messagePath'], $catalog);
349
        }
350 32
    }
351
352
    /**
353
     * Saves messages to database
354
     *
355
     * @param array $messages
356
     * @param Connection $db
357
     * @param string $sourceMessageTable
358
     * @param string $messageTable
359
     * @param bool $removeUnused
360
     * @param array $languages
361
     * @param bool $markUnused
362
     */
363 10
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
364
    {
365 10
        $currentMessages = [];
366 10
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
367 10
        foreach ($rows as $row) {
368 9
            $currentMessages[$row['category']][$row['id']] = $row['message'];
369
        }
370
371 10
        $currentLanguages = [];
372 10
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
373 10
        foreach ($rows as $row) {
374 9
            $currentLanguages[] = $row['language'];
375
        }
376 10
        $missingLanguages = [];
377 10
        if (!empty($currentLanguages)) {
378 9
            $missingLanguages = array_diff($languages, $currentLanguages);
379
        }
380
381 10
        $new = [];
382 10
        $obsolete = [];
383
384 10
        foreach ($messages as $category => $msgs) {
385 10
            $msgs = array_unique($msgs);
386
387 10
            if (isset($currentMessages[$category])) {
388 7
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
389 7
                $obsolete += array_diff($currentMessages[$category], $msgs);
390
            } else {
391 6
                $new[$category] = $msgs;
392
            }
393
        }
394
395 10
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
396 7
            $obsolete += $currentMessages[$category];
397
        }
398
399 10
        if (!$removeUnused) {
400 9
            foreach ($obsolete as $pk => $msg) {
401 6
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
402 5
                    unset($obsolete[$pk]);
403
                }
404
            }
405
        }
406
407 10
        $obsolete = array_keys($obsolete);
408 10
        $this->stdout('Inserting new messages...');
409 10
        $savedFlag = false;
410
411 10
        foreach ($new as $category => $msgs) {
412 10
            foreach ($msgs as $msg) {
413 9
                $savedFlag = true;
414 9
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
415 9
                foreach ($languages as $language) {
416 9
                    $db->createCommand()
417 9
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
418 10
                       ->execute();
419
                }
420
            }
421
        }
422
423 10
        if (!empty($missingLanguages)) {
424 1
            $updatedMessages = [];
425 1
            $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
426 1
            foreach ($rows as $row) {
427 1
                $updatedMessages[$row['category']][$row['id']] = $row['message'];
428
            }
429 1
            foreach ($updatedMessages as $category => $msgs) {
430 1
                foreach ($msgs as $id => $msg) {
431 1
                    $savedFlag = true;
432 1
                    foreach ($missingLanguages as $language) {
433 1
                        $db->createCommand()
434 1
                            ->insert($messageTable, ['id' => $id, 'language' => $language])
435 1
                            ->execute();
436
                    }
437
                }
438
            }
439
        }
440
441 10
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
442 10
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
443
444 10
        if (empty($obsolete)) {
445 6
            $this->stdout("Nothing obsoleted...skipped.\n");
446 6
            return;
447
        }
448
449 7
        if ($removeUnused) {
450 1
            $db->createCommand()
451 1
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
452 1
               ->execute();
453 1
            $this->stdout("deleted.\n");
454 6
        } elseif ($markUnused) {
455 6
            $rows = (new Query())
456 6
                ->select(['id', 'message'])
457 6
                ->from($sourceMessageTable)
458 6
                ->where(['in', 'id', $obsolete])
459 6
                ->all($db);
460
461 6
            foreach ($rows as $row) {
462 6
                $db->createCommand()->update(
463 6
                    $sourceMessageTable,
464 6
                    ['message' => '@@' . $row['message'] . '@@'],
465 6
                    ['id' => $row['id']]
466 6
                )->execute();
467
            }
468 6
            $this->stdout("updated.\n");
469
        } else {
470
            $this->stdout("kept untouched.\n");
471
        }
472 7
    }
473
474
    /**
475
     * Extracts messages from a file
476
     *
477
     * @param string $fileName name of the file to extract messages from
478
     * @param string $translator name of the function used to translate messages
479
     * @param array $ignoreCategories message categories to ignore.
480
     * This parameter is available since version 2.0.4.
481
     * @return array
482
     */
483 32
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
484
    {
485 32
        $this->stdout('Extracting messages from ');
486 32
        $this->stdout($fileName, Console::FG_CYAN);
487 32
        $this->stdout("...\n");
488
489 32
        $subject = file_get_contents($fileName);
490 32
        $messages = [];
491 32
        $tokens = token_get_all($subject);
492 32
        foreach ((array) $translator as $currentTranslator) {
493 32
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
494 32
            array_shift($translatorTokens);
495 32
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
496
        }
497
498 32
        $this->stdout("\n");
499
500 32
        return $messages;
501
    }
502
503
    /**
504
     * Extracts messages from a parsed PHP tokens list.
505
     * @param array $tokens tokens to be processed.
506
     * @param array $translatorTokens translator tokens.
507
     * @param array $ignoreCategories message categories to ignore.
508
     * @return array messages.
509
     */
510 32
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
511
    {
512 32
        $messages = [];
513 32
        $translatorTokensCount = count($translatorTokens);
514 32
        $matchedTokensCount = 0;
515 32
        $buffer = [];
516 32
        $pendingParenthesisCount = 0;
517
518 32
        foreach ($tokens as $token) {
519
            // finding out translator call
520 32
            if ($matchedTokensCount < $translatorTokensCount) {
521 32
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
522 32
                    $matchedTokensCount++;
523
                } else {
524 32
                    $matchedTokensCount = 0;
525
                }
526 32
            } elseif ($matchedTokensCount === $translatorTokensCount) {
527
                // translator found
528
529
                // end of function call
530 32
                if ($this->tokensEqual(')', $token)) {
531 32
                    $pendingParenthesisCount--;
532
533 32
                    if ($pendingParenthesisCount === 0) {
534
                        // end of translator call or end of something that we can't extract
535 32
                        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) {
536
                            // is valid call we can extract
537 32
                            $category = stripcslashes($buffer[0][1]);
538 32
                            $category = mb_substr($category, 1, -1);
539
540 32
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
541
542 32
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
543 32
                                $i = 3;
544 32
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
545 3
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
546 3
                                    $i += 2;
547
                                }
548
549 32
                                $message = stripcslashes($fullMessage);
550 32
                                $messages[$category][] = $message;
551
                            }
552
553 32
                            $nestedTokens = array_slice($buffer, 3);
554 32
                            if (count($nestedTokens) > $translatorTokensCount) {
555
                                // search for possible nested translator calls
556 6
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
557
                            }
558
                        } else {
559
                            // invalid call or dynamic call we can't extract
560
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
561
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
562
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
563
                        }
564
565
                        // prepare for the next match
566 32
                        $matchedTokensCount = 0;
567 32
                        $pendingParenthesisCount = 0;
568 32
                        $buffer = [];
569
                    } else {
570 3
                        $buffer[] = $token;
571
                    }
572 32
                } elseif ($this->tokensEqual('(', $token)) {
573
                    // count beginning of function call, skipping translator beginning
574 32
                    if ($pendingParenthesisCount > 0) {
575 3
                        $buffer[] = $token;
576
                    }
577 32
                    $pendingParenthesisCount++;
578 32
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
579
                    // ignore comments and whitespaces
580 32
                    $buffer[] = $token;
581
                }
582
            }
583
        }
584
585 32
        return $messages;
586
    }
587
588
    /**
589
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
590
     * Examples:
591
     *
592
     * - `myapp` - will be ignored only `myapp` category;
593
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
594
     *
595
     * @param string $category category that is checked
596
     * @param array $ignoreCategories message categories to ignore.
597
     * @return bool
598
     * @since 2.0.7
599
     */
600 32
    protected function isCategoryIgnored($category, array $ignoreCategories)
601
    {
602 32
        if (!empty($ignoreCategories)) {
603 3
            if (in_array($category, $ignoreCategories, true)) {
604 3
                return true;
605
            }
606 3
            foreach ($ignoreCategories as $pattern) {
607 3
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
608 3
                    return true;
609
                }
610
            }
611
        }
612
613 32
        return false;
614
    }
615
616
    /**
617
     * Finds out if two PHP tokens are equal
618
     *
619
     * @param array|string $a
620
     * @param array|string $b
621
     * @return bool
622
     * @since 2.0.1
623
     */
624 32
    protected function tokensEqual($a, $b)
625
    {
626 32
        if (is_string($a) && is_string($b)) {
627 32
            return $a === $b;
628
        }
629 32
        if (isset($a[0], $a[1], $b[0], $b[1])) {
630 32
            return $a[0] === $b[0] && $a[1] == $b[1];
631
        }
632 32
        return false;
633
    }
634
635
    /**
636
     * Finds out a line of the first non-char PHP token found
637
     *
638
     * @param array $tokens
639
     * @return int|string
640
     * @since 2.0.1
641
     */
642
    protected function getLine($tokens)
643
    {
644
        foreach ($tokens as $token) {
645
            if (isset($token[2])) {
646
                return $token[2];
647
            }
648
        }
649
        return 'unknown';
650
    }
651
652
    /**
653
     * Writes messages into PHP files
654
     *
655
     * @param array $messages
656
     * @param string $dirName name of the directory to write to
657
     * @param bool $overwrite if existing file should be overwritten without backup
658
     * @param bool $removeUnused if obsolete translations should be removed
659
     * @param bool $sort if translations should be sorted
660
     * @param bool $markUnused if obsolete translations should be marked
661
     */
662 11
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
663
    {
664 11
        foreach ($messages as $category => $msgs) {
665 11
            $file = str_replace('\\', '/', "$dirName/$category.php");
666 11
            $path = dirname($file);
667 11
            FileHelper::createDirectory($path);
668 11
            $msgs = array_values(array_unique($msgs));
669 11
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
670 11
            $this->stdout("Saving messages to $coloredFileName...\n");
671 11
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
672
        }
673 11
    }
674
675
    /**
676
     * Writes category messages into PHP file
677
     *
678
     * @param array $messages
679
     * @param string $fileName name of the file to write to
680
     * @param bool $overwrite if existing file should be overwritten without backup
681
     * @param bool $removeUnused if obsolete translations should be removed
682
     * @param bool $sort if translations should be sorted
683
     * @param string $category message category
684
     * @param bool $markUnused if obsolete translations should be marked
685
     * @return int exit code
686
     */
687 11
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
688
    {
689 11
        if (is_file($fileName)) {
690 7
            $rawExistingMessages = require($fileName);
691 7
            $existingMessages = $rawExistingMessages;
692 7
            sort($messages);
693 7
            ksort($existingMessages);
694 7
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
695 4
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
696 4
                return self::EXIT_CODE_NORMAL;
697
            }
698 4
            unset($rawExistingMessages);
699 4
            $merged = [];
700 4
            $untranslated = [];
701 4
            foreach ($messages as $message) {
702 4
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
703 2
                    $merged[$message] = $existingMessages[$message];
704
                } else {
705 4
                    $untranslated[] = $message;
706
                }
707
            }
708 4
            ksort($merged);
709 4
            sort($untranslated);
710 4
            $todo = [];
711 4
            foreach ($untranslated as $message) {
712 4
                $todo[$message] = '';
713
            }
714 4
            ksort($existingMessages);
715 4
            foreach ($existingMessages as $message => $translation) {
716 4
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
717 1
                    if (!empty($translation) && (!$markUnused || (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
718
                        $todo[$message] = $translation;
719
                    } else {
720 1
                        $todo[$message] = '@@' . $translation . '@@';
721
                    }
722
                }
723
            }
724 4
            $merged = array_merge($todo, $merged);
725 4
            if ($sort) {
726
                ksort($merged);
727
            }
728 4
            if (false === $overwrite) {
729
                $fileName .= '.merged';
730
            }
731 4
            $this->stdout("Translation merged.\n");
732
        } else {
733 8
            $merged = [];
734 8
            foreach ($messages as $message) {
735 8
                $merged[$message] = '';
736
            }
737 8
            ksort($merged);
738
        }
739
740 11
        $array = VarDumper::export($merged);
741
        $content = <<<EOD
742
<?php
743
/**
744
 * Message translations.
745
 *
746 11
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
747
 * It contains the localizable messages extracted from source code.
748
 * You may modify this file by translating the extracted messages.
749
 *
750
 * Each array element represents the translation (value) of a message (key).
751
 * If the value is empty, the message is considered as not translated.
752
 * Messages that no longer need translation will have their translations
753
 * enclosed between a pair of '@@' marks.
754
 *
755
 * Message string can be used with plural forms format. Check i18n section
756
 * of the guide for details.
757
 *
758
 * NOTE: this file must be saved in UTF-8 encoding.
759
 */
760 11
return $array;
761
762
EOD;
763
764 11
        if (file_put_contents($fileName, $content) === false) {
765
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
766
            return self::EXIT_CODE_ERROR;
767
        }
768
769 11
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
770 11
        return self::EXIT_CODE_NORMAL;
771
    }
772
773
    /**
774
     * Writes messages into PO file
775
     *
776
     * @param array $messages
777
     * @param string $dirName name of the directory to write to
778
     * @param bool $overwrite if existing file should be overwritten without backup
779
     * @param bool $removeUnused if obsolete translations should be removed
780
     * @param bool $sort if translations should be sorted
781
     * @param string $catalog message catalog
782
     * @param bool $markUnused if obsolete translations should be marked
783
     */
784 11
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
785
    {
786 11
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
787 11
        FileHelper::createDirectory(dirname($file));
788 11
        $this->stdout("Saving messages to $file...\n");
789
790 11
        $poFile = new GettextPoFile();
791
792 11
        $merged = [];
793 11
        $todos = [];
794
795 11
        $hasSomethingToWrite = false;
796 11
        foreach ($messages as $category => $msgs) {
797 11
            $notTranslatedYet = [];
798 11
            $msgs = array_values(array_unique($msgs));
799
800 11
            if (is_file($file)) {
801 7
                $existingMessages = $poFile->load($file, $category);
802
803 7
                sort($msgs);
804 7
                ksort($existingMessages);
805 7
                if (array_keys($existingMessages) == $msgs) {
806 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
807
808 4
                    sort($msgs);
809 4
                    foreach ($msgs as $message) {
810 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
811
                    }
812 4
                    ksort($merged);
813 4
                    continue;
814
                }
815
816
                // merge existing message translations with new message translations
817 4
                foreach ($msgs as $message) {
818 4
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
819 2
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
820
                    } else {
821 4
                        $notTranslatedYet[] = $message;
822
                    }
823
                }
824 4
                ksort($merged);
825 4
                sort($notTranslatedYet);
826
827
                // collect not yet translated messages
828 4
                foreach ($notTranslatedYet as $message) {
829 4
                    $todos[$category . chr(4) . $message] = '';
830
                }
831
832
                // add obsolete unused messages
833 4
                foreach ($existingMessages as $message => $translation) {
834 4
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
835 1
                        if (!empty($translation) && (!$markUnused || (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
836
                            $todos[$category . chr(4) . $message] = $translation;
837
                        } else {
838 1
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
839
                        }
840
                    }
841
                }
842
843 4
                $merged = array_merge($todos, $merged);
844 4
                if ($sort) {
845
                    ksort($merged);
846
                }
847
848 4
                if ($overwrite === false) {
849
                    $file .= '.merged';
850
                }
851
            } else {
852 8
                sort($msgs);
853 8
                foreach ($msgs as $message) {
854 8
                    $merged[$category . chr(4) . $message] = '';
855
                }
856 8
                ksort($merged);
857
            }
858 11
            $this->stdout("Category \"$category\" merged.\n");
859 11
            $hasSomethingToWrite = true;
860
        }
861 11
        if ($hasSomethingToWrite) {
862 11
            $poFile->save($file, $merged);
863 11
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
864
        } else {
865 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
866
        }
867 11
    }
868
869
    /**
870
     * Writes messages into POT file
871
     *
872
     * @param array $messages
873
     * @param string $dirName name of the directory to write to
874
     * @param string $catalog message catalog
875
     * @since 2.0.6
876
     */
877
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
878
    {
879
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
880
        FileHelper::createDirectory(dirname($file));
881
        $this->stdout("Saving messages to $file...\n");
882
883
        $poFile = new GettextPoFile();
884
885
        $merged = [];
886
887
        $hasSomethingToWrite = false;
888
        foreach ($messages as $category => $msgs) {
889
            $msgs = array_values(array_unique($msgs));
890
891
            sort($msgs);
892
            foreach ($msgs as $message) {
893
                $merged[$category . chr(4) . $message] = '';
894
            }
895
            $this->stdout("Category \"$category\" merged.\n");
896
            $hasSomethingToWrite = true;
897
        }
898
        if ($hasSomethingToWrite) {
899
            ksort($merged);
900
            $poFile->save($file, $merged);
901
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
902
        } else {
903
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
904
        }
905
    }
906
}
907