Completed
Push — php7-travis-apcu ( 9bbcee...fd63c3 )
by Alexander
14:47
created

MessageController::extractMessagesFromTokens()   C

Complexity

Conditions 20
Paths 14

Size

Total Lines 76
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 40
CRAP Score 20.136

Importance

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

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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

$a = canBeFalseAndNull();

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

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
223
                return ExitCode::OK;
224
            }
225
        }
226
227 3
        $array = VarDumper::export($this->getOptionValues($this->action->id));
228
        $content = <<<EOD
229
<?php
230
/**
231 3
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
232
 *
233 3
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
234
 * It contains parameters for source code messages extraction.
235
 * You may modify this file to suit your needs.
236
 *
237 3
 * You can use 'yii {$this->id}/{$this->action->id}-template' command to create
238
 * template configuration file with detailed description for each parameter.
239
 */
240 3
return $array;
241
242
EOD;
243
244 3
        if (file_put_contents($filePath, $content) === false) {
245
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
246
            return ExitCode::UNSPECIFIED_ERROR;
247
        }
248
249 3
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
250 3
        return ExitCode::OK;
251
    }
252
253
    /**
254
     * Creates a configuration file template for the "extract" command.
255
     *
256
     * The created configuration file contains detailed instructions on
257
     * how to customize it to fit for your needs. After customization,
258
     * you may use this configuration file with the "extract" command.
259
     *
260
     * @param string $filePath output file name or alias.
261
     * @return int CLI exit code
262
     * @throws Exception on failure.
263
     */
264
    public function actionConfigTemplate($filePath)
265
    {
266
        $filePath = Yii::getAlias($filePath);
267
268
        if (file_exists($filePath)) {
269
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->confirm("File '{$...wish to overwrite it?") of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

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

$a = canBeFalseAndNull();

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

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