Completed
Push — master ( d2781c...0559a9 )
by Carsten
09:39
created

MessageController::saveMessagesToDb()   F

Complexity

Conditions 25
Paths 3840

Size

Total Lines 110
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 73
CRAP Score 25.0015

Importance

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

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