Completed
Push — master ( 2914db...46bf3c )
by Carsten
13:36
created

MessageController::saveMessagesToDb()   F

Complexity

Conditions 25
Paths 3840

Size

Total Lines 110
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 650

Importance

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

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 27
    public function options($actionID)
159
    {
160 27
        return array_merge(parent::options($actionID), [
161 27
            '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 2
    public function actionConfig($filePath)
218
    {
219 2
        $filePath = Yii::getAlias($filePath);
220 2
        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 2
        $array = VarDumper::export($this->getOptionValues($this->action->id));
227
        $content = <<<EOD
228
<?php
229
/**
230 2
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
231
 *
232 2
 * 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 2
 * 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 2
return $array;
240
241
EOD;
242
243 2
        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 2
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
249 2
        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 25
    public function actionExtract($configFile = null)
294
    {
295 25
        $this->initConfig($configFile);
296
297 23
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
298
299 23
        $messages = [];
300 23
        foreach ($files as $file) {
301 23
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
302
        }
303
304 23
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
305
306 23
        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
        } elseif ($this->config['format'] === 'db') {
319
            /** @var Connection $db */
320
            $db = Instance::ensure($this->config['db'], Connection::className());
321
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
322
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
323
            $this->saveMessagesToDb(
324
                $messages,
325
                $db,
326
                $sourceMessageTable,
327
                $messageTable,
328
                $this->config['removeUnused'],
329
                $this->config['languages'],
330
                $this->config['markUnused']
331
            );
332
        } elseif ($this->config['format'] === 'pot') {
333
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
334
        }
335 23
    }
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
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
349
    {
350
        $currentMessages = [];
351
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
352
        foreach ($rows as $row) {
353
            $currentMessages[$row['category']][$row['id']] = $row['message'];
354
        }
355
356
        $currentLanguages = [];
357
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
358
        foreach ($rows as $row) {
359
            $currentLanguages[] = $row['language'];
360
        }
361
        $missingLanguages = [];
362
        if (!empty($currentLanguages)) {
363
            $missingLanguages = array_diff($languages, $currentLanguages);
364
        }
365
366
        $new = [];
367
        $obsolete = [];
368
369
        foreach ($messages as $category => $msgs) {
370
            $msgs = array_unique($msgs);
371
372
            if (isset($currentMessages[$category])) {
373
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
374
                $obsolete += array_diff($currentMessages[$category], $msgs);
375
            } else {
376
                $new[$category] = $msgs;
377
            }
378
        }
379
380
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
381
            $obsolete += $currentMessages[$category];
382
        }
383
384
        if (!$removeUnused) {
385
            foreach ($obsolete as $pk => $msg) {
386
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
387
                    unset($obsolete[$pk]);
388
                }
389
            }
390
        }
391
392
        $obsolete = array_keys($obsolete);
393
        $this->stdout('Inserting new messages...');
394
        $savedFlag = false;
395
396
        foreach ($new as $category => $msgs) {
397
            foreach ($msgs as $msg) {
398
                $savedFlag = true;
399
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
400
                foreach ($languages as $language) {
401
                    $db->createCommand()
402
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
403
                       ->execute();
404
                }
405
            }
406
        }
407
408
        if (!empty($missingLanguages)) {
409
            $updatedMessages = [];
410
            $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
411
            foreach ($rows as $row) {
412
                $updatedMessages[$row['category']][$row['id']] = $row['message'];
413
            }
414
            foreach ($updatedMessages as $category => $msgs) {
415
                foreach ($msgs as $id => $msg) {
416
                    $savedFlag = true;
417
                    foreach ($missingLanguages as $language) {
418
                        $db->createCommand()
419
                            ->insert($messageTable, ['id' => $id, 'language' => $language])
420
                            ->execute();
421
                    }
422
                }
423
            }
424
        }
425
426
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
427
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
428
429
        if (empty($obsolete)) {
430
            $this->stdout("Nothing obsoleted...skipped.\n");
431
            return;
432
        }
433
434
        if ($removeUnused) {
435
            $db->createCommand()
436
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
437
               ->execute();
438
            $this->stdout("deleted.\n");
439
        } elseif ($markUnused) {
440
            $rows = (new Query())
441
                ->select(['id', 'message'])
442
                ->from($sourceMessageTable)
443
                ->where(['in', 'id', $obsolete])
444
                ->all($db);
445
446
            foreach ($rows as $row) {
447
                $db->createCommand()->update(
448
                    $sourceMessageTable,
449
                    ['message' => '@@' . $row['message'] . '@@'],
450
                    ['id' => $row['id']]
451
                )->execute();
452
            }
453
            $this->stdout("updated.\n");
454
        } else {
455
            $this->stdout("kept untouched.\n");
456
        }
457
    }
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 23
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
469
    {
470 23
        $this->stdout('Extracting messages from ');
471 23
        $this->stdout($fileName, Console::FG_CYAN);
472 23
        $this->stdout("...\n");
473
474 23
        $subject = file_get_contents($fileName);
475 23
        $messages = [];
476 23
        $tokens = token_get_all($subject);
477 23
        foreach ((array) $translator as $currentTranslator) {
478 23
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
479 23
            array_shift($translatorTokens);
480 23
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
481
        }
482
483 23
        $this->stdout("\n");
484
485 23
        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 23
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
496
    {
497 23
        $messages = [];
498 23
        $translatorTokensCount = count($translatorTokens);
499 23
        $matchedTokensCount = 0;
500 23
        $buffer = [];
501 23
        $pendingParenthesisCount = 0;
502
503 23
        foreach ($tokens as $token) {
504
            // finding out translator call
505 23
            if ($matchedTokensCount < $translatorTokensCount) {
506 23
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
507 23
                    $matchedTokensCount++;
508
                } else {
509 23
                    $matchedTokensCount = 0;
510
                }
511 23
            } elseif ($matchedTokensCount === $translatorTokensCount) {
512
                // translator found
513
514
                // end of function call
515 23
                if ($this->tokensEqual(')', $token)) {
516 23
                    $pendingParenthesisCount--;
517
518 23
                    if ($pendingParenthesisCount === 0) {
519
                        // end of translator call or end of something that we can't extract
520 23
                        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 23
                            $category = stripcslashes($buffer[0][1]);
523 23
                            $category = mb_substr($category, 1, -1);
524
525 23
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
526 23
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
527 23
                                $i = 3;
528 23
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
529 2
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
530 2
                                    $i += 2;
531
                                }
532
533 23
                                $message = stripcslashes($fullMessage);
534 23
                                $messages[$category][] = $message;
535
                            }
536
537 23
                            $nestedTokens = array_slice($buffer, 3);
538 23
                            if (count($nestedTokens) > $translatorTokensCount) {
539
                                // search for possible nested translator calls
540 23
                                $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 23
                        $matchedTokensCount = 0;
551 23
                        $pendingParenthesisCount = 0;
552 23
                        $buffer = [];
553
                    } else {
554 23
                        $buffer[] = $token;
555
                    }
556 23
                } elseif ($this->tokensEqual('(', $token)) {
557
                    // count beginning of function call, skipping translator beginning
558 23
                    if ($pendingParenthesisCount > 0) {
559 2
                        $buffer[] = $token;
560
                    }
561 23
                    $pendingParenthesisCount++;
562 23
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
563
                    // ignore comments and whitespaces
564 23
                    $buffer[] = $token;
565
                }
566
            }
567
        }
568
569 23
        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 23
    protected function isCategoryIgnored($category, array $ignoreCategories)
585
    {
586 23
        if (!empty($ignoreCategories)) {
587 2
            if (in_array($category, $ignoreCategories, true)) {
588 2
                return true;
589
            }
590 2
            foreach ($ignoreCategories as $pattern) {
591 2
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
592 2
                    return true;
593
                }
594
            }
595
        }
596
597 23
        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 23
    protected function tokensEqual($a, $b)
609
    {
610 23
        if (is_string($a) && is_string($b)) {
611 23
            return $a === $b;
612
        }
613 23
        if (isset($a[0], $a[1], $b[0], $b[1])) {
614 23
            return $a[0] === $b[0] && $a[1] == $b[1];
615
        }
616 23
        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 25
    protected function initConfig($configFile)
881
    {
882 25
        $configFileContent = [];
883 25
        if ($configFile !== null) {
884 25
            $configFile = Yii::getAlias($configFile);
885 25
            if (!is_file($configFile)) {
886 2
                throw new Exception("The configuration file does not exist: $configFile");
887
            }
888 23
            $configFileContent = require $configFile;
889
        }
890
891 23
        $this->config = array_merge(
892 23
            $this->getOptionValues($this->action->id),
893 23
            $configFileContent,
894 23
            $this->getPassedOptionValues()
895
        );
896 23
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
897 23
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
898
899 23
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
900
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
901
        }
902 23
        if (!is_dir($this->config['sourcePath'])) {
903
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
904
        }
905 23
        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 23
        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 23
        if (empty($this->config['languages'])) {
917
            throw new Exception('Languages cannot be empty.');
918
        }
919
920 23
        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 23
    }
942
}
943