Completed
Push — readme-redesign ( e2fd40...17eb05 )
by Alexander
108:51 queued 68:52
created

MessageController::saveMessagesToDb()   F

Complexity

Conditions 25
Paths 3840

Size

Total Lines 110
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 110
rs 2
cc 25
eloc 75
nc 3840
nop 7

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