Completed
Push — fix-message-markunused ( 63ac53 )
by Alexander
12:45
created

MessageController::saveMessagesToDb()   F

Complexity

Conditions 25
Paths 3840

Size

Total Lines 110
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 74
CRAP Score 25

Importance

Changes 0
Metric Value
dl 0
loc 110
ccs 74
cts 74
cp 1
rs 2
c 0
b 0
f 0
cc 25
eloc 75
nc 3840
nop 7
crap 25

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\console\ExitCode;
13
use yii\db\Connection;
14
use yii\db\Query;
15
use yii\di\Instance;
16
use yii\helpers\Console;
17
use yii\helpers\FileHelper;
18
use yii\helpers\VarDumper;
19
use yii\i18n\GettextPoFile;
20
21
/**
22
 * Extracts messages to be translated from source files.
23
 *
24
 * The extracted messages can be saved the following depending on `format`
25
 * setting in config file:
26
 *
27
 * - PHP message source files.
28
 * - ".po" files.
29
 * - Database.
30
 *
31
 * Usage:
32
 * 1. Create a configuration file using the 'message/config' command:
33
 *    yii message/config /path/to/myapp/messages/config.php
34
 * 2. Edit the created config file, adjusting it for your web application needs.
35
 * 3. Run the 'message/extract' command, using created config:
36
 *    yii message /path/to/myapp/messages/config.php
37
 *
38
 * @author Qiang Xue <[email protected]>
39
 * @since 2.0
40
 */
41
class MessageController extends \yii\console\Controller
42
{
43
    /**
44
     * @var string controller default action ID.
45
     */
46
    public $defaultAction = 'extract';
47
    /**
48
     * @var string required, root directory of all source files.
49
     */
50
    public $sourcePath = '@yii';
51
    /**
52
     * @var string required, root directory containing message translations.
53
     */
54
    public $messagePath = '@yii/messages';
55
    /**
56
     * @var array required, list of language codes that the extracted messages
57
     * should be translated to. For example, ['zh-CN', 'de'].
58
     */
59
    public $languages = [];
60
    /**
61
     * @var string the name of the function for translating messages.
62
     * Defaults to 'Yii::t'. This is used as a mark to find the messages to be
63
     * translated. You may use a string for single function name or an array for
64
     * multiple function names.
65
     */
66
    public $translator = '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 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
        '.svn',
95
        '.git',
96
        '.gitignore',
97
        '.gitkeep',
98
        '.hgignore',
99
        '.hgkeep',
100
        '/messages',
101
        '/BaseYii.php', // contains examples about Yii:t()
102
    ];
103
    /**
104
     * @var array list of patterns that specify which files (not directories) should be processed.
105
     * If empty or not set, all files will be processed.
106
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
107
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
108
     */
109
    public $only = ['*.php'];
110
    /**
111
     * @var string generated file format. Can be "php", "db", "po" or "pot".
112
     */
113
    public $format = 'php';
114
    /**
115
     * @var string connection component ID for "db" format.
116
     */
117
    public $db = 'db';
118
    /**
119
     * @var string custom name for source message table for "db" format.
120
     */
121
    public $sourceMessageTable = '{{%source_message}}';
122
    /**
123
     * @var string custom name for translation message table for "db" format.
124
     */
125
    public $messageTable = '{{%message}}';
126
    /**
127
     * @var string name of the file that will be used for translations for "po" format.
128
     */
129
    public $catalog = 'messages';
130
    /**
131
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
132
     * @see isCategoryIgnored
133
     */
134
    public $ignoreCategories = [];
135
    /**
136
     * @var string File header in generated PHP file with messages. This property is used only if [[$format]] is "php".
137
     * @since 2.0.13
138
     */
139
    public $phpFileHeader = '';
140
    /**
141
     * @var string|null DocBlock used for messages array in generated PHP file. If `null`, default DocBlock will be used.
142
     * This property is used only if [[$format]] is "php".
143
     * @since 2.0.13
144
     */
145
    public $phpDocBlock;
146
147
    /**
148
     * @var array Config for messages extraction.
149
     * @see actionExtract()
150
     * @see initConfig()
151
     * @since 2.0.13
152
     */
153
    protected $config;
154
155
156
    /**
157
     * @inheritdoc
158
     */
159 42
    public function options($actionID)
160
    {
161 42
        return array_merge(parent::options($actionID), [
162 42
            'sourcePath',
163
            'messagePath',
164
            'languages',
165
            'translator',
166
            'sort',
167
            'overwrite',
168
            'removeUnused',
169
            'markUnused',
170
            'except',
171
            'only',
172
            'format',
173
            'db',
174
            'sourceMessageTable',
175
            'messageTable',
176
            'catalog',
177
            'ignoreCategories',
178
            'phpFileHeader',
179
            'phpDocBlock',
180
        ]);
181
    }
182
183
    /**
184
     * @inheritdoc
185
     * @since 2.0.8
186
     */
187
    public function optionAliases()
188
    {
189
        return array_merge(parent::optionAliases(), [
190
            'c' => 'catalog',
191
            'e' => 'except',
192
            'f' => 'format',
193
            'i' => 'ignoreCategories',
194
            'l' => 'languages',
195
            'u' => 'markUnused',
196
            'p' => 'messagePath',
197
            'o' => 'only',
198
            'w' => 'overwrite',
199
            'S' => 'sort',
200
            't' => 'translator',
201
            'm' => 'sourceMessageTable',
202
            's' => 'sourcePath',
203
            'r' => 'removeUnused',
204
        ]);
205
    }
206
207
    /**
208
     * Creates a configuration file for the "extract" command using command line options specified.
209
     *
210
     * The generated configuration file contains parameters required
211
     * for source code messages extraction.
212
     * You may use this configuration file with the "extract" command.
213
     *
214
     * @param string $filePath output file name or alias.
215
     * @return int CLI exit code
216
     * @throws Exception on failure.
217
     */
218 3
    public function actionConfig($filePath)
219
    {
220 3
        $filePath = Yii::getAlias($filePath);
221 3
        if (file_exists($filePath)) {
222
            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...
223
                return ExitCode::OK;
224
            }
225
        }
226
227 3
        $array = VarDumper::export($this->getOptionValues($this->action->id));
228
        $content = <<<EOD
229
<?php
230
/**
231 3
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
232
 *
233 3
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
234
 * It contains parameters for source code messages extraction.
235
 * You may modify this file to suit your needs.
236
 *
237 3
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
238
 * template configuration file with detailed description for each parameter.
239
 */
240 3
return $array;
241
242
EOD;
243
244 3
        if (file_put_contents($filePath, $content) === false) {
245
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
246
            return ExitCode::UNSPECIFIED_ERROR;
247
        }
248
249 3
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
250 3
        return ExitCode::OK;
251
    }
252
253
    /**
254
     * Creates a configuration file template for the "extract" command.
255
     *
256
     * The created configuration file contains detailed instructions on
257
     * how to customize it to fit for your needs. After customization,
258
     * you may use this configuration file with the "extract" command.
259
     *
260
     * @param string $filePath output file name or alias.
261
     * @return int CLI exit code
262
     * @throws Exception on failure.
263
     */
264
    public function actionConfigTemplate($filePath)
265
    {
266
        $filePath = Yii::getAlias($filePath);
267
268
        if (file_exists($filePath)) {
269
            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...
270
                return ExitCode::OK;
271
            }
272
        }
273
274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
275
            $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED);
276
            return ExitCode::UNSPECIFIED_ERROR;
277
        }
278
279
        $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
280
        return ExitCode::OK;
281
    }
282
283
    /**
284
     * Extracts messages to be translated from source code.
285
     *
286
     * This command will search through source code files and extract
287
     * messages that need to be translated in different languages.
288
     *
289
     * @param string $configFile the path or alias of the configuration file.
290
     * You may use the "yii message/config" command to generate
291
     * this file and then customize it for your needs.
292
     * @throws Exception on failure.
293
     */
294 39
    public function actionExtract($configFile = null)
295
    {
296 39
        $this->initConfig($configFile);
297
298 36
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
299
300 36
        $messages = [];
301 36
        foreach ($files as $file) {
302 36
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
303
        }
304
305 36
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
306
307 36
        if (in_array($this->config['format'], ['php', 'po'])) {
308 25
            foreach ($this->config['languages'] as $language) {
309 25
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
310 25
                if (!is_dir($dir) && !@mkdir($dir)) {
311
                    throw new Exception("Directory '{$dir}' can not be created.");
312
                }
313 25
                if ($this->config['format'] === 'po') {
314 12
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
315
                } else {
316 25
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
317
                }
318
            }
319 11
        } elseif ($this->config['format'] === 'db') {
320
            /** @var Connection $db */
321 11
            $db = Instance::ensure($this->config['db'], Connection::className());
322 11
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
323 11
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
324 11
            $this->saveMessagesToDb(
325 11
                $messages,
326 11
                $db,
327 11
                $sourceMessageTable,
328 11
                $messageTable,
329 11
                $this->config['removeUnused'],
330 11
                $this->config['languages'],
331 11
                $this->config['markUnused']
332
            );
333
        } elseif ($this->config['format'] === 'pot') {
334
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
335
        }
336 36
    }
337
338
    /**
339
     * Saves messages to database.
340
     *
341
     * @param array $messages
342
     * @param Connection $db
343
     * @param string $sourceMessageTable
344
     * @param string $messageTable
345
     * @param bool $removeUnused
346
     * @param array $languages
347
     * @param bool $markUnused
348
     */
349 11
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
350
    {
351 11
        $currentMessages = [];
352 11
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
353 11
        foreach ($rows as $row) {
354 10
            $currentMessages[$row['category']][$row['id']] = $row['message'];
355
        }
356
357 11
        $currentLanguages = [];
358 11
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
359 11
        foreach ($rows as $row) {
360 10
            $currentLanguages[] = $row['language'];
361
        }
362 11
        $missingLanguages = [];
363 11
        if (!empty($currentLanguages)) {
364 10
            $missingLanguages = array_diff($languages, $currentLanguages);
365
        }
366
367 11
        $new = [];
368 11
        $obsolete = [];
369
370 11
        foreach ($messages as $category => $msgs) {
371 11
            $msgs = array_unique($msgs);
372
373 11
            if (isset($currentMessages[$category])) {
374 8
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
375 8
                $obsolete += array_diff($currentMessages[$category], $msgs);
376
            } else {
377 11
                $new[$category] = $msgs;
378
            }
379
        }
380
381 11
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
382 7
            $obsolete += $currentMessages[$category];
383
        }
384
385 11
        if (!$removeUnused) {
386 10
            foreach ($obsolete as $pk => $msg) {
387 7
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
388 7
                    unset($obsolete[$pk]);
389
                }
390
            }
391
        }
392
393 11
        $obsolete = array_keys($obsolete);
394 11
        $this->stdout('Inserting new messages...');
395 11
        $savedFlag = false;
396
397 11
        foreach ($new as $category => $msgs) {
398 11
            foreach ($msgs as $msg) {
399 10
                $savedFlag = true;
400 10
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
401 10
                foreach ($languages as $language) {
402 10
                    $db->createCommand()
403 10
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
404 11
                       ->execute();
405
                }
406
            }
407
        }
408
409 11
        if (!empty($missingLanguages)) {
410 1
            $updatedMessages = [];
411 1
            $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
412 1
            foreach ($rows as $row) {
413 1
                $updatedMessages[$row['category']][$row['id']] = $row['message'];
414
            }
415 1
            foreach ($updatedMessages as $category => $msgs) {
416 1
                foreach ($msgs as $id => $msg) {
417 1
                    $savedFlag = true;
418 1
                    foreach ($missingLanguages as $language) {
419 1
                        $db->createCommand()
420 1
                            ->insert($messageTable, ['id' => $id, 'language' => $language])
421 1
                            ->execute();
422
                    }
423
                }
424
            }
425
        }
426
427 11
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
428 11
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
429
430 11
        if (empty($obsolete)) {
431 6
            $this->stdout("Nothing obsoleted...skipped.\n");
432 6
            return;
433
        }
434
435 8
        if ($removeUnused) {
436 1
            $db->createCommand()
437 1
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
438 1
               ->execute();
439 1
            $this->stdout("deleted.\n");
440 7
        } elseif ($markUnused) {
441 6
            $rows = (new Query())
442 6
                ->select(['id', 'message'])
443 6
                ->from($sourceMessageTable)
444 6
                ->where(['in', 'id', $obsolete])
445 6
                ->all($db);
446
447 6
            foreach ($rows as $row) {
448 6
                $db->createCommand()->update(
449 6
                    $sourceMessageTable,
450 6
                    ['message' => '@@' . $row['message'] . '@@'],
451 6
                    ['id' => $row['id']]
452 6
                )->execute();
453
            }
454 6
            $this->stdout("updated.\n");
455
        } else {
456 1
            $this->stdout("kept untouched.\n");
457
        }
458 8
    }
459
460
    /**
461
     * Extracts messages from a file.
462
     *
463
     * @param string $fileName name of the file to extract messages from
464
     * @param string $translator name of the function used to translate messages
465
     * @param array $ignoreCategories message categories to ignore.
466
     * This parameter is available since version 2.0.4.
467
     * @return array
468
     */
469 36
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
470
    {
471 36
        $this->stdout('Extracting messages from ');
472 36
        $this->stdout($fileName, Console::FG_CYAN);
473 36
        $this->stdout("...\n");
474
475 36
        $subject = file_get_contents($fileName);
476 36
        $messages = [];
477 36
        $tokens = token_get_all($subject);
478 36
        foreach ((array) $translator as $currentTranslator) {
479 36
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
480 36
            array_shift($translatorTokens);
481 36
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
482
        }
483
484 36
        $this->stdout("\n");
485
486 36
        return $messages;
487
    }
488
489
    /**
490
     * Extracts messages from a parsed PHP tokens list.
491
     * @param array $tokens tokens to be processed.
492
     * @param array $translatorTokens translator tokens.
493
     * @param array $ignoreCategories message categories to ignore.
494
     * @return array messages.
495
     */
496 36
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
497
    {
498 36
        $messages = [];
499 36
        $translatorTokensCount = count($translatorTokens);
500 36
        $matchedTokensCount = 0;
501 36
        $buffer = [];
502 36
        $pendingParenthesisCount = 0;
503
504 36
        foreach ($tokens as $token) {
505
            // finding out translator call
506 36
            if ($matchedTokensCount < $translatorTokensCount) {
507 36
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
508 36
                    $matchedTokensCount++;
509
                } else {
510 36
                    $matchedTokensCount = 0;
511
                }
512 36
            } elseif ($matchedTokensCount === $translatorTokensCount) {
513
                // translator found
514
515
                // end of function call
516 36
                if ($this->tokensEqual(')', $token)) {
517 36
                    $pendingParenthesisCount--;
518
519 36
                    if ($pendingParenthesisCount === 0) {
520
                        // end of translator call or end of something that we can't extract
521 36
                        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) {
522
                            // is valid call we can extract
523 36
                            $category = stripcslashes($buffer[0][1]);
524 36
                            $category = mb_substr($category, 1, -1);
525
526 36
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
527 36
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
528 36
                                $i = 3;
529 36
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
530 3
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
531 3
                                    $i += 2;
532
                                }
533
534 36
                                $message = stripcslashes($fullMessage);
535 36
                                $messages[$category][] = $message;
536
                            }
537
538 36
                            $nestedTokens = array_slice($buffer, 3);
539 36
                            if (count($nestedTokens) > $translatorTokensCount) {
540
                                // search for possible nested translator calls
541 36
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
542
                            }
543
                        } else {
544
                            // invalid call or dynamic call we can't extract
545
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
546
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
547
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
548
                        }
549
550
                        // prepare for the next match
551 36
                        $matchedTokensCount = 0;
552 36
                        $pendingParenthesisCount = 0;
553 36
                        $buffer = [];
554
                    } else {
555 36
                        $buffer[] = $token;
556
                    }
557 36
                } elseif ($this->tokensEqual('(', $token)) {
558
                    // count beginning of function call, skipping translator beginning
559 36
                    if ($pendingParenthesisCount > 0) {
560 3
                        $buffer[] = $token;
561
                    }
562 36
                    $pendingParenthesisCount++;
563 36
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
564
                    // ignore comments and whitespaces
565 36
                    $buffer[] = $token;
566
                }
567
            }
568
        }
569
570 36
        return $messages;
571
    }
572
573
    /**
574
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
575
     *
576
     * Examples:
577
     *
578
     * - `myapp` - will be ignored only `myapp` category;
579
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
580
     *
581
     * @param string $category category that is checked
582
     * @param array $ignoreCategories message categories to ignore.
583
     * @return bool
584
     * @since 2.0.7
585
     */
586 36
    protected function isCategoryIgnored($category, array $ignoreCategories)
587
    {
588 36
        if (!empty($ignoreCategories)) {
589 3
            if (in_array($category, $ignoreCategories, true)) {
590 3
                return true;
591
            }
592 3
            foreach ($ignoreCategories as $pattern) {
593 3
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
594 3
                    return true;
595
                }
596
            }
597
        }
598
599 36
        return false;
600
    }
601
602
    /**
603
     * Finds out if two PHP tokens are equal.
604
     *
605
     * @param array|string $a
606
     * @param array|string $b
607
     * @return bool
608
     * @since 2.0.1
609
     */
610 36
    protected function tokensEqual($a, $b)
611
    {
612 36
        if (is_string($a) && is_string($b)) {
613 36
            return $a === $b;
614
        }
615 36
        if (isset($a[0], $a[1], $b[0], $b[1])) {
616 36
            return $a[0] === $b[0] && $a[1] == $b[1];
617
        }
618
619 36
        return false;
620
    }
621
622
    /**
623
     * Finds out a line of the first non-char PHP token found.
624
     *
625
     * @param array $tokens
626
     * @return int|string
627
     * @since 2.0.1
628
     */
629
    protected function getLine($tokens)
630
    {
631
        foreach ($tokens as $token) {
632
            if (isset($token[2])) {
633
                return $token[2];
634
            }
635
        }
636
637
        return 'unknown';
638
    }
639
640
    /**
641
     * Writes messages into PHP files.
642
     *
643
     * @param array $messages
644
     * @param string $dirName name of the directory to write to
645
     * @param bool $overwrite if existing file should be overwritten without backup
646
     * @param bool $removeUnused if obsolete translations should be removed
647
     * @param bool $sort if translations should be sorted
648
     * @param bool $markUnused if obsolete translations should be marked
649
     */
650 13
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
651
    {
652 13
        foreach ($messages as $category => $msgs) {
653 13
            $file = str_replace('\\', '/', "$dirName/$category.php");
654 13
            $path = dirname($file);
655 13
            FileHelper::createDirectory($path);
656 13
            $msgs = array_values(array_unique($msgs));
657 13
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
658 13
            $this->stdout("Saving messages to $coloredFileName...\n");
659 13
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
660
        }
661 13
    }
662
663
    /**
664
     * Writes category messages into PHP file.
665
     *
666
     * @param array $messages
667
     * @param string $fileName name of the file to write to
668
     * @param bool $overwrite if existing file should be overwritten without backup
669
     * @param bool $removeUnused if obsolete translations should be removed
670
     * @param bool $sort if translations should be sorted
671
     * @param string $category message category
672
     * @param bool $markUnused if obsolete translations should be marked
673
     * @return int exit code
674
     */
675 13
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
676
    {
677 13
        if (is_file($fileName)) {
678 8
            $rawExistingMessages = require $fileName;
679 8
            $existingMessages = $rawExistingMessages;
680 8
            sort($messages);
681 8
            ksort($existingMessages);
682 8
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
683 4
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
684 4
                return ExitCode::OK;
685
            }
686 5
            unset($rawExistingMessages);
687 5
            $merged = [];
688 5
            $untranslated = [];
689 5
            foreach ($messages as $message) {
690 5
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
691 2
                    $merged[$message] = $existingMessages[$message];
692
                } else {
693 5
                    $untranslated[] = $message;
694
                }
695
            }
696 5
            ksort($merged);
697 5
            sort($untranslated);
698 5
            $todo = [];
699 5
            foreach ($untranslated as $message) {
700 5
                $todo[$message] = '';
701
            }
702 5
            ksort($existingMessages);
703 5
            foreach ($existingMessages as $message => $translation) {
704 5
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
705 2
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
706 1
                        $todo[$message] = $translation;
707
                    } else {
708 5
                        $todo[$message] = '@@' . $translation . '@@';
709
                    }
710
                }
711
            }
712 5
            $merged = array_merge($todo, $merged);
713 5
            if ($sort) {
714
                ksort($merged);
715
            }
716 5
            if (false === $overwrite) {
717
                $fileName .= '.merged';
718
            }
719 5
            $this->stdout("Translation merged.\n");
720
        } else {
721 9
            $merged = [];
722 9
            foreach ($messages as $message) {
723 9
                $merged[$message] = '';
724
            }
725 9
            ksort($merged);
726
        }
727
728 13
        $array = VarDumper::export($merged);
729
        $content = <<<EOD
730
<?php
731 13
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
732 13
return $array;
733
734
EOD;
735
736 13
        if (file_put_contents($fileName, $content) === false) {
737
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
738
            return ExitCode::UNSPECIFIED_ERROR;
739
        }
740
741 13
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
742 13
        return ExitCode::OK;
743
    }
744
745
    /**
746
     * Writes messages into PO file.
747
     *
748
     * @param array $messages
749
     * @param string $dirName name of the directory to write to
750
     * @param bool $overwrite if existing file should be overwritten without backup
751
     * @param bool $removeUnused if obsolete translations should be removed
752
     * @param bool $sort if translations should be sorted
753
     * @param string $catalog message catalog
754
     * @param bool $markUnused if obsolete translations should be marked
755
     */
756 12
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
757
    {
758 12
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
759 12
        FileHelper::createDirectory(dirname($file));
760 12
        $this->stdout("Saving messages to $file...\n");
761
762 12
        $poFile = new GettextPoFile();
763
764 12
        $merged = [];
765 12
        $todos = [];
766
767 12
        $hasSomethingToWrite = false;
768 12
        foreach ($messages as $category => $msgs) {
769 12
            $notTranslatedYet = [];
770 12
            $msgs = array_values(array_unique($msgs));
771
772 12
            if (is_file($file)) {
773 8
                $existingMessages = $poFile->load($file, $category);
774
775 8
                sort($msgs);
776 8
                ksort($existingMessages);
777 8
                if (array_keys($existingMessages) == $msgs) {
778 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
779
780 4
                    sort($msgs);
781 4
                    foreach ($msgs as $message) {
782 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
783
                    }
784 4
                    ksort($merged);
785 4
                    continue;
786
                }
787
788
                // merge existing message translations with new message translations
789 5
                foreach ($msgs as $message) {
790 5
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
791 2
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
792
                    } else {
793 5
                        $notTranslatedYet[] = $message;
794
                    }
795
                }
796 5
                ksort($merged);
797 5
                sort($notTranslatedYet);
798
799
                // collect not yet translated messages
800 5
                foreach ($notTranslatedYet as $message) {
801 5
                    $todos[$category . chr(4) . $message] = '';
802
                }
803
804
                // add obsolete unused messages
805 5
                foreach ($existingMessages as $message => $translation) {
806 5
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
807 2
                        if (!$markUnused || (!empty($translation) && ((substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@')))) {
808 1
                            $todos[$category . chr(4) . $message] = $translation;
809
                        } else {
810 5
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
811
                        }
812
                    }
813
                }
814
815 5
                $merged = array_merge($todos, $merged);
816 5
                if ($sort) {
817
                    ksort($merged);
818
                }
819
820 5
                if ($overwrite === false) {
821 5
                    $file .= '.merged';
822
                }
823
            } else {
824 8
                sort($msgs);
825 8
                foreach ($msgs as $message) {
826 8
                    $merged[$category . chr(4) . $message] = '';
827
                }
828 8
                ksort($merged);
829
            }
830 12
            $this->stdout("Category \"$category\" merged.\n");
831 12
            $hasSomethingToWrite = true;
832
        }
833 12
        if ($hasSomethingToWrite) {
834 12
            $poFile->save($file, $merged);
835 12
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
836
        } else {
837 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
838
        }
839 12
    }
840
841
    /**
842
     * Writes messages into POT file.
843
     *
844
     * @param array $messages
845
     * @param string $dirName name of the directory to write to
846
     * @param string $catalog message catalog
847
     * @since 2.0.6
848
     */
849
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
850
    {
851
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
852
        FileHelper::createDirectory(dirname($file));
853
        $this->stdout("Saving messages to $file...\n");
854
855
        $poFile = new GettextPoFile();
856
857
        $merged = [];
858
859
        $hasSomethingToWrite = false;
860
        foreach ($messages as $category => $msgs) {
861
            $msgs = array_values(array_unique($msgs));
862
863
            sort($msgs);
864
            foreach ($msgs as $message) {
865
                $merged[$category . chr(4) . $message] = '';
866
            }
867
            $this->stdout("Category \"$category\" merged.\n");
868
            $hasSomethingToWrite = true;
869
        }
870
        if ($hasSomethingToWrite) {
871
            ksort($merged);
872
            $poFile->save($file, $merged);
873
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
874
        } else {
875
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
876
        }
877
    }
878
879
    /**
880
     * @param string $configFile
881
     * @throws Exception If configuration file does not exists.
882
     * @since 2.0.13
883
     */
884 39
    protected function initConfig($configFile)
885
    {
886 39
        $configFileContent = [];
887 39
        if ($configFile !== null) {
888 39
            $configFile = Yii::getAlias($configFile);
889 39
            if (!is_file($configFile)) {
890 3
                throw new Exception("The configuration file does not exist: $configFile");
891
            }
892 36
            $configFileContent = require $configFile;
893
        }
894
895 36
        $this->config = array_merge(
896 36
            $this->getOptionValues($this->action->id),
897 36
            $configFileContent,
898 36
            $this->getPassedOptionValues()
899
        );
900 36
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
901 36
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
902
903 36
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
904
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
905
        }
906 36
        if (!is_dir($this->config['sourcePath'])) {
907
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
908
        }
909 36
        if (empty($this->config['format']) || !in_array($this->config['format'], ['php', 'po', 'pot', 'db'])) {
910
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
911
        }
912 36
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
913 25
            if (!isset($this->config['messagePath'])) {
914
                throw new Exception('The configuration file must specify "messagePath".');
915
            }
916 25
            if (!is_dir($this->config['messagePath'])) {
917
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
918
            }
919
        }
920 36
        if (empty($this->config['languages'])) {
921
            throw new Exception('Languages cannot be empty.');
922
        }
923
924 36
        if ($this->config['format'] === 'php' && $this->config['phpDocBlock'] === null) {
925
            $this->config['phpDocBlock'] = <<<DOCBLOCK
926
/**
927
 * Message translations.
928
 *
929
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
930
 * It contains the localizable messages extracted from source code.
931
 * You may modify this file by translating the extracted messages.
932
 *
933
 * Each array element represents the translation (value) of a message (key).
934
 * If the value is empty, the message is considered as not translated.
935
 * Messages that no longer need translation will have their translations
936
 * enclosed between a pair of '@@' marks.
937
 *
938
 * Message string can be used with plural forms format. Check i18n section
939
 * of the guide for details.
940
 *
941
 * NOTE: this file must be saved in UTF-8 encoding.
942
 */
943
DOCBLOCK;
944
        }
945 36
    }
946
}
947