Passed
Pull Request — master (#19934)
by Alexander
10:29
created

MessageController::saveMessagesToDb()   F

Complexity

Conditions 31
Paths > 20000

Size

Total Lines 144
Code Lines 90

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 90
CRAP Score 31

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 31
eloc 90
c 1
b 0
f 0
nc 89856
nop 7
dl 0
loc 144
ccs 90
cts 90
cp 1
crap 31
rs 0

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 https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://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|string[] the name of the function for translating messages.
62
     * This is used as a mark to find the messages to be translated.
63
     * You may use a string for single function name or an array for multiple function names.
64
     */
65
    public $translator = ['Yii::t', '\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|null 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
        '.*',
94
        '/.*',
95
        '/messages',
96
        '/tests',
97
        '/runtime',
98
        '/vendor',
99
        '/BaseYii.php', // contains examples about Yii::t()
100
    ];
101
    /**
102
     * @var array|null list of patterns that specify which files (not directories) should be processed.
103
     * If empty or not set, all files will be processed.
104
     * See helpers/FileHelper::findFiles() description for pattern matching rules.
105
     * If a file/directory matches both a pattern in "only" and "except", it will NOT be processed.
106
     */
107
    public $only = ['*.php'];
108
    /**
109
     * @var string generated file format. Can be "php", "db", "po" or "pot".
110
     */
111
    public $format = 'php';
112
    /**
113
     * @var string connection component ID for "db" format.
114
     */
115
    public $db = 'db';
116
    /**
117
     * @var string custom name for source message table for "db" format.
118
     */
119
    public $sourceMessageTable = '{{%source_message}}';
120
    /**
121
     * @var string custom name for translation message table for "db" format.
122
     */
123
    public $messageTable = '{{%message}}';
124
    /**
125
     * @var string name of the file that will be used for translations for "po" format.
126
     */
127
    public $catalog = 'messages';
128
    /**
129
     * @var array message categories to ignore. For example, 'yii', 'app*', 'widgets/menu', etc.
130
     * @see isCategoryIgnored
131
     */
132
    public $ignoreCategories = [];
133
    /**
134
     * @var string File header in generated PHP file with messages. This property is used only if [[$format]] is "php".
135
     * @since 2.0.13
136
     */
137
    public $phpFileHeader = '';
138
    /**
139
     * @var string|null DocBlock used for messages array in generated PHP file. If `null`, default DocBlock will be used.
140
     * This property is used only if [[$format]] is "php".
141
     * @since 2.0.13
142
     */
143
    public $phpDocBlock;
144
145
    /**
146
     * @var array Config for messages extraction.
147
     * @see actionExtract()
148
     * @see initConfig()
149
     * @since 2.0.13
150
     */
151
    protected $config;
152
153
154
    /**
155
     * {@inheritdoc}
156
     */
157 68
    public function options($actionID)
158
    {
159 68
        return array_merge(parent::options($actionID), [
160 68
            'sourcePath',
161
            'messagePath',
162
            'languages',
163
            'translator',
164
            'sort',
165
            'overwrite',
166
            'removeUnused',
167
            'markUnused',
168
            'except',
169
            'only',
170
            'format',
171
            'db',
172
            'sourceMessageTable',
173
            'messageTable',
174
            'catalog',
175
            'ignoreCategories',
176
            'phpFileHeader',
177
            'phpDocBlock',
178
        ]);
179
    }
180
181
    /**
182
     * {@inheritdoc}
183
     * @since 2.0.8
184
     */
185
    public function optionAliases()
186
    {
187
        return array_merge(parent::optionAliases(), [
188
            'c' => 'catalog',
189
            'e' => 'except',
190
            'f' => 'format',
191
            'i' => 'ignoreCategories',
192
            'l' => 'languages',
193
            'u' => 'markUnused',
194
            'p' => 'messagePath',
195
            'o' => 'only',
196
            'w' => 'overwrite',
197
            'S' => 'sort',
198
            't' => 'translator',
199
            'm' => 'sourceMessageTable',
200
            's' => 'sourcePath',
201
            'r' => 'removeUnused',
202
        ]);
203
    }
204
205
    /**
206
     * Creates a configuration file for the "extract" command using command line options specified.
207
     *
208
     * The generated configuration file contains parameters required
209
     * for source code messages extraction.
210
     * You may use this configuration file with the "extract" command.
211
     *
212
     * @param string $filePath output file name or alias.
213
     * @return int CLI exit code
214
     * @throws Exception on failure.
215
     */
216 6
    public function actionConfig($filePath)
217
    {
218 6
        $filePath = Yii::getAlias($filePath);
219 6
        $dir = dirname($filePath);
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $path of dirname() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

219
        $dir = dirname(/** @scrutinizer ignore-type */ $filePath);
Loading history...
220
221 6
        if (file_exists($filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

221
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
222
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
223
                return ExitCode::OK;
224
            }
225
        }
226
227 6
        $array = VarDumper::export($this->getOptionValues($this->action->id));
228
        $content = <<<EOD
229 6
<?php
230
/**
231 6
 * Configuration file for 'yii {$this->id}/{$this->defaultAction}' command.
232
 *
233 6
 * 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 6
 * 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 6
return $array;
241
242
EOD;
243
244 6
        if (FileHelper::createDirectory($dir) === false || file_put_contents($filePath, $content, LOCK_EX) === false) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_put_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

244
        if (FileHelper::createDirectory($dir) === false || file_put_contents(/** @scrutinizer ignore-type */ $filePath, $content, LOCK_EX) === false) {
Loading history...
245
            $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED);
246
            return ExitCode::UNSPECIFIED_ERROR;
247
        }
248
249 6
        $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN);
250 6
        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)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

268
        if (file_exists(/** @scrutinizer ignore-type */ $filePath)) {
Loading history...
269
            if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
270
                return ExitCode::OK;
271
            }
272
        }
273
274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $to of copy() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

274
        if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), /** @scrutinizer ignore-type */ $filePath)) {
Loading history...
Bug introduced by
It seems like Yii::getAlias('@yii/views/messageConfig.php') can also be of type false; however, parameter $from of copy() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

274
        if (!copy(/** @scrutinizer ignore-type */ Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) {
Loading history...
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|null $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 62
    public function actionExtract($configFile = null)
295
    {
296 62
        $this->initConfig($configFile);
297
298 59
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
299
300 59
        $messages = [];
301 59
        foreach ($files as $file) {
302 53
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
303
        }
304
305 59
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
306
307 59
        if (in_array($this->config['format'], ['php', 'po'])) {
308 45
            foreach ($this->config['languages'] as $language) {
309 45
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
310 45
                if (!is_dir($dir) && !@mkdir($dir)) {
311
                    throw new Exception("Directory '{$dir}' can not be created.");
312
                }
313 45
                if ($this->config['format'] === 'po') {
314 16
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
315
                } else {
316 29
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
317
                }
318
            }
319 14
        } elseif ($this->config['format'] === 'db') {
320
            /** @var Connection $db */
321 14
            $db = Instance::ensure($this->config['db'], Connection::className());
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

321
            $db = Instance::ensure($this->config['db'], /** @scrutinizer ignore-deprecated */ Connection::className());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
322 14
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
323 14
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
324 14
            $this->saveMessagesToDb(
325 14
                $messages,
326
                $db,
327
                $sourceMessageTable,
328
                $messageTable,
329 14
                $this->config['removeUnused'],
330 14
                $this->config['languages'],
331 14
                $this->config['markUnused']
332
            );
333
        } elseif ($this->config['format'] === 'pot') {
334
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
335
        }
336 59
    }
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 14
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
350
    {
351 14
        $this->stdout("UPDATING DB\n\n");
352
        
353 14
        $this->stdout("SOURCE MESSAGE TABLE\n\n");
354
        
355 14
        $currentMessages = [];
356 14
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
357 14
        foreach ($rows as $row) {
358 13
            $currentMessages[$row['category']][$row['id']] = $row['message'];
359
        }
360
        
361 14
        $new = [];
362 14
        $obsolete = [];
363
        
364 14
        foreach ($messages as $category => $msgs) {
365 14
            $msgs = array_unique($msgs);
366
367 14
            if (isset($currentMessages[$category])) {
368 9
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
369
                // obsolete msgs per category
370 9
                $obsolete += array_diff($currentMessages[$category], $msgs);
371
            } else {
372 8
                $new[$category] = $msgs;
373
            }
374
        }
375
        
376
        // obsolete categories
377 14
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
378 9
            $obsolete += $currentMessages[$category];
379
        }
380
        
381 14
        if (!$removeUnused) {
382 13
            foreach ($obsolete as $pk => $msg) {
383
                // skip already marked unused
384 10
                if (strncmp($msg, '@@', 2) === 0 && substr($msg, -2) === '@@') {
385 6
                    unset($obsolete[$pk]);
386
                }
387
            }
388
        }        
389
        
390 14
        $this->stdout('Inserting new messages...');
391 14
        $insertCount = 0;
392
393 14
        foreach ($new as $category => $msgs) {
394 14
            foreach ($msgs as $msg) {
395 12
                $insertCount++;
396 12
                $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
397
            }
398
        }
399
        
400 14
        $this->stdout($insertCount ? "{$insertCount} saved.\n" : "Nothing to save.\n");
401
        
402 14
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
403
        
404 14
        if (empty($obsolete)) {
405 6
            $this->stdout("Nothing obsoleted...skipped.\n");
406
        }
407
        
408 14
        if ($obsolete && $removeUnused) {
409 1
            $affected=$db->createCommand()
410 1
               ->delete($sourceMessageTable, ['in', 'id', array_keys($obsolete)])
411 1
               ->execute();
412 1
            $this->stdout("{$affected} deleted.\n");
413 13
        } elseif ($obsolete && $markUnused) {
414 8
            $marked=0;
415 8
            $rows = (new Query())
416 8
                ->select(['id', 'message'])
417 8
                ->from($sourceMessageTable)
418 8
                ->where(['in', 'id', array_keys($obsolete)])
419 8
                ->all($db);
420
421 8
            foreach ($rows as $row) {
422 8
                $marked++;
423 8
                $db->createCommand()->update(
424 8
                    $sourceMessageTable,
425 8
                    ['message' => '@@' . $row['message'] . '@@'],
426 8
                    ['id' => $row['id']]
427 8
                )->execute();
428
            }
429 8
            $this->stdout("{$marked} updated.\n");
430 8
        } elseif ($obsolete) {
431 2
            $this->stdout("kept untouched.\n");
432
        }
433
         
434 14
        $this->stdout("\n\nMESSAGE TABLE\n\n");
435
        
436
        // get fresh message id list
437 14
        $freshMessagesIds = [];
438 14
        $rows = (new Query())->select(['id'])->from($sourceMessageTable)->all($db);
439 14
        foreach ($rows as $row) {
440 14
            $freshMessagesIds[] = $row['id'];
441
        }
442
            
443 14
        $this->stdout("Generating missing rows...");
444 14
        $generatedMissingRows=[];
445
        
446 14
        foreach ($languages as $language) {
447 14
          $count=0;
448
          
449
          // get list of ids of translations for this language
450 14
          $msgRowsIds = [];
451 14
          $msgRows = (new Query())->select(['id'])->from($messageTable)->where([
452 14
              'language'=>$language,
453 14
          ])->all($db);
454 14
          foreach ($msgRows as $row) {
455 13
              $msgRowsIds[] = $row['id'];
456
          }
457
          
458
          // insert missing
459 14
          foreach ($freshMessagesIds as $id) {
460 14
            if (!in_array($id, $msgRowsIds)) {
461 12
              $db->createCommand()
462 12
                 ->insert($messageTable, ['id' => $id, 'language' => $language])
463 12
                 ->execute();
464 12
              $count++;
465
            }
466
          }
467 14
          if ($count) {
468 12
            $generatedMissingRows[]="{$count} for {$language}";
469
          }
470
        }
471
        
472 14
        $this->stdout($generatedMissingRows ? implode(", ", $generatedMissingRows).".\n" : "Nothing to do.\n");
473
        
474 14
        $this->stdout("Dropping unused languages...");
475 14
        $droppedLanguages=[];
476
        
477 14
        $currentLanguages = [];
478 14
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
479 14
        foreach ($rows as $row) {
480 14
            $currentLanguages[] = $row['language'];
481
        }
482
        
483 14
        foreach ($currentLanguages as $currentLanguage) {
484 14
          if (!in_array($currentLanguage, $languages)) {
485 1
            $deleted=$db->createCommand()->delete($messageTable, "language=:language", [
486 1
                'language'=>$currentLanguage,
487 1
            ])->execute();
488 1
            $droppedLanguages[]="removed {$deleted} rows for $currentLanguage";
489
          }
490
        }
491
        
492 14
        $this->stdout($droppedLanguages ? implode(", ", $droppedLanguages).".\n" : "Nothing to do.\n");
493 14
    }
494
495
    /**
496
     * Extracts messages from a file.
497
     *
498
     * @param string $fileName name of the file to extract messages from
499
     * @param string $translator name of the function used to translate messages
500
     * @param array $ignoreCategories message categories to ignore.
501
     * This parameter is available since version 2.0.4.
502
     * @return array
503
     */
504 53
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
505
    {
506 53
        $this->stdout('Extracting messages from ');
507 53
        $this->stdout($fileName, Console::FG_CYAN);
508 53
        $this->stdout("...\n");
509
510 53
        $subject = file_get_contents($fileName);
511 53
        $messages = [];
512 53
        $tokens = token_get_all($subject);
513 53
        foreach ((array) $translator as $currentTranslator) {
514 53
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
515 53
            array_shift($translatorTokens);
516 53
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
517
        }
518
519 53
        $this->stdout("\n");
520
521 53
        return $messages;
522
    }
523
524
    /**
525
     * Extracts messages from a parsed PHP tokens list.
526
     * @param array $tokens tokens to be processed.
527
     * @param array $translatorTokens translator tokens.
528
     * @param array $ignoreCategories message categories to ignore.
529
     * @return array messages.
530
     */
531 53
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
532
    {
533 53
        $messages = [];
534 53
        $translatorTokensCount = count($translatorTokens);
535 53
        $matchedTokensCount = 0;
536 53
        $buffer = [];
537 53
        $pendingParenthesisCount = 0;
538
539 53
        foreach ($tokens as $tokenIndex => $token) {
540
            // finding out translator call
541 53
            if ($matchedTokensCount < $translatorTokensCount) {
542 53
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
543 53
                    $matchedTokensCount++;
544
                } else {
545 53
                    $matchedTokensCount = 0;
546
                }
547 53
            } elseif ($matchedTokensCount === $translatorTokensCount) {
548
                // translator found
549
550
                // end of function call
551 53
                if ($this->tokensEqual(')', $token)) {
552 53
                    $pendingParenthesisCount--;
553
554 53
                    if ($pendingParenthesisCount === 0) {
555
                        // end of translator call or end of something that we can't extract
556 53
                        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) {
557
                            // is valid call we can extract
558 53
                            $category = stripcslashes($buffer[0][1]);
559 53
                            $category = mb_substr($category, 1, -1);
560
561 53
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
562 53
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
563 53
                                $i = 3;
564 53
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
565 3
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
566 3
                                    $i += 2;
567
                                }
568
569 53
                                $message = stripcslashes($fullMessage);
570 53
                                $messages[$category][] = $message;
571
                            }
572
573 53
                            $nestedTokens = array_slice($buffer, 3);
574 53
                            if (count($nestedTokens) > $translatorTokensCount) {
575
                                // search for possible nested translator calls
576 53
                                $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($nestedTokens, $translatorTokens, $ignoreCategories));
577
                            }
578
                        } else {
579
                            // invalid call or dynamic call we can't extract
580
                            $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
581
                            $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
582
                            $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
583
                        }
584
585
                        // prepare for the next match
586 53
                        $matchedTokensCount = 0;
587 53
                        $pendingParenthesisCount = 0;
588 53
                        $buffer = [];
589
                    } else {
590 53
                        $buffer[] = $token;
591
                    }
592 53
                } elseif ($this->tokensEqual('(', $token)) {
593
                    // count beginning of function call, skipping translator beginning
594
595
                    // If we are not yet inside the translator, make sure that it's beginning of the real translator.
596
                    // See https://github.com/yiisoft/yii2/issues/16828
597 53
                    if ($pendingParenthesisCount === 0) {
598 53
                        $previousTokenIndex = $tokenIndex - $matchedTokensCount - 1;
599 53
                        if (is_array($tokens[$previousTokenIndex])) {
600 53
                            $previousToken = $tokens[$previousTokenIndex][0];
601 53
                            if (in_array($previousToken, [T_OBJECT_OPERATOR, T_PAAMAYIM_NEKUDOTAYIM], true)) {
602 3
                                $matchedTokensCount = 0;
603 3
                                continue;
604
                            }
605
                        }
606
                    }
607
608 53
                    if ($pendingParenthesisCount > 0) {
609 6
                        $buffer[] = $token;
610
                    }
611 53
                    $pendingParenthesisCount++;
612 53
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
613
                    // ignore comments and whitespaces
614 53
                    $buffer[] = $token;
615
                }
616
            }
617
        }
618
619 53
        return $messages;
620
    }
621
622
    /**
623
     * The method checks, whether the $category is ignored according to $ignoreCategories array.
624
     *
625
     * Examples:
626
     *
627
     * - `myapp` - will be ignored only `myapp` category;
628
     * - `myapp*` - will be ignored by all categories beginning with `myapp` (`myapp`, `myapplication`, `myapprove`, `myapp/widgets`, `myapp.widgets`, etc).
629
     *
630
     * @param string $category category that is checked
631
     * @param array $ignoreCategories message categories to ignore.
632
     * @return bool
633
     * @since 2.0.7
634
     */
635 53
    protected function isCategoryIgnored($category, array $ignoreCategories)
636
    {
637 53
        if (!empty($ignoreCategories)) {
638 3
            if (in_array($category, $ignoreCategories, true)) {
639 3
                return true;
640
            }
641 3
            foreach ($ignoreCategories as $pattern) {
642 3
                if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) {
643 3
                    return true;
644
                }
645
            }
646
        }
647
648 53
        return false;
649
    }
650
651
    /**
652
     * Finds out if two PHP tokens are equal.
653
     *
654
     * @param array|string $a
655
     * @param array|string $b
656
     * @return bool
657
     * @since 2.0.1
658
     */
659 53
    protected function tokensEqual($a, $b)
660
    {
661 53
        if (is_string($a) && is_string($b)) {
662 53
            return $a === $b;
663
        }
664 53
        if (isset($a[0], $a[1], $b[0], $b[1])) {
665 53
            return $a[0] === $b[0] && $a[1] == $b[1];
666
        }
667
668 53
        return false;
669
    }
670
671
    /**
672
     * Finds out a line of the first non-char PHP token found.
673
     *
674
     * @param array $tokens
675
     * @return int|string
676
     * @since 2.0.1
677
     */
678
    protected function getLine($tokens)
679
    {
680
        foreach ($tokens as $token) {
681
            if (isset($token[2])) {
682
                return $token[2];
683
            }
684
        }
685
686
        return 'unknown';
687
    }
688
689
    /**
690
     * Writes messages into PHP files.
691
     *
692
     * @param array $messages
693
     * @param string $dirName name of the directory to write to
694
     * @param bool $overwrite if existing file should be overwritten without backup
695
     * @param bool $removeUnused if obsolete translations should be removed
696
     * @param bool $sort if translations should be sorted
697
     * @param bool $markUnused if obsolete translations should be marked
698
     */
699 29
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
700
    {
701 29
        foreach ($messages as $category => $msgs) {
702 23
            $file = str_replace('\\', '/', "$dirName/$category.php");
703 23
            $path = dirname($file);
704 23
            FileHelper::createDirectory($path);
705 23
            $msgs = array_values(array_unique($msgs));
706 23
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
707 23
            $this->stdout("Saving messages to $coloredFileName...\n");
708 23
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
709
        }
710
711 29
        if ($removeUnused) {
712 7
            $this->deleteUnusedPhpMessageFiles(array_keys($messages), $dirName);
713
        }
714 29
    }
715
716
    /**
717
     * Writes category messages into PHP file.
718
     *
719
     * @param array $messages
720
     * @param string $fileName name of the file to write to
721
     * @param bool $overwrite if existing file should be overwritten without backup
722
     * @param bool $removeUnused if obsolete translations should be removed
723
     * @param bool $sort if translations should be sorted
724
     * @param string $category message category
725
     * @param bool $markUnused if obsolete translations should be marked
726
     * @return int exit code
727
     */
728 23
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
729
    {
730 23
        if (is_file($fileName)) {
731 16
            $rawExistingMessages = require $fileName;
732 16
            $existingMessages = $rawExistingMessages;
733 16
            sort($messages);
734 16
            ksort($existingMessages);
735 16
            if (array_keys($existingMessages) === $messages && (!$sort || array_keys($rawExistingMessages) === $messages)) {
736 10
                $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
737 10
                return ExitCode::OK;
738
            }
739 7
            unset($rawExistingMessages);
740 7
            $merged = [];
741 7
            $untranslated = [];
742 7
            foreach ($messages as $message) {
743 7
                if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
744 4
                    $merged[$message] = $existingMessages[$message];
745
                } else {
746 6
                    $untranslated[] = $message;
747
                }
748
            }
749 7
            ksort($merged);
750 7
            sort($untranslated);
751 7
            $todo = [];
752 7
            foreach ($untranslated as $message) {
753 6
                $todo[$message] = '';
754
            }
755 7
            ksort($existingMessages);
756 7
            foreach ($existingMessages as $message => $translation) {
757 7
                if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
758 3
                    if (!$markUnused || (!empty($translation) && (strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0))) {
759 2
                        $todo[$message] = $translation;
760
                    } else {
761 1
                        $todo[$message] = '@@' . $translation . '@@';
762
                    }
763
                }
764
            }
765 7
            $merged = array_merge($merged, $todo);
766 7
            if ($sort) {
767 1
                ksort($merged);
768
            }
769 7
            if (false === $overwrite) {
770
                $fileName .= '.merged';
771
            }
772 7
            $this->stdout("Translation merged.\n");
773
        } else {
774 11
            $merged = [];
775 11
            foreach ($messages as $message) {
776 11
                $merged[$message] = '';
777
            }
778 11
            ksort($merged);
779
        }
780
781 17
        $array = VarDumper::export($merged);
782
        $content = <<<EOD
783 17
<?php
784 17
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
785 17
return $array;
786
787
EOD;
788
789 17
        if (file_put_contents($fileName, $content, LOCK_EX) === false) {
790
            $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED);
791
            return ExitCode::UNSPECIFIED_ERROR;
792
        }
793
794 17
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
795 17
        return ExitCode::OK;
796
    }
797
798
    /**
799
     * Writes messages into PO file.
800
     *
801
     * @param array $messages
802
     * @param string $dirName name of the directory to write to
803
     * @param bool $overwrite if existing file should be overwritten without backup
804
     * @param bool $removeUnused if obsolete translations should be removed
805
     * @param bool $sort if translations should be sorted
806
     * @param string $catalog message catalog
807
     * @param bool $markUnused if obsolete translations should be marked
808
     */
809 16
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
810
    {
811 16
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
812 16
        FileHelper::createDirectory(dirname($file));
813 16
        $this->stdout("Saving messages to $file...\n");
814
815 16
        $poFile = new GettextPoFile();
816
817 16
        $merged = [];
818 16
        $todos = [];
819
820 16
        $hasSomethingToWrite = false;
821 16
        foreach ($messages as $category => $msgs) {
822 16
            $notTranslatedYet = [];
823 16
            $msgs = array_values(array_unique($msgs));
824
825 16
            if (is_file($file)) {
826 10
                $existingMessages = $poFile->load($file, $category);
827
828 10
                sort($msgs);
829 10
                ksort($existingMessages);
830 10
                if (array_keys($existingMessages) == $msgs) {
831 4
                    $this->stdout("Nothing new in \"$category\" category...\n");
832
833 4
                    sort($msgs);
834 4
                    foreach ($msgs as $message) {
835 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
836
                    }
837 4
                    ksort($merged);
838 4
                    continue;
839
                }
840
841
                // merge existing message translations with new message translations
842 7
                foreach ($msgs as $message) {
843 7
                    if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
844 4
                        $merged[$category . chr(4) . $message] = $existingMessages[$message];
845
                    } else {
846 6
                        $notTranslatedYet[] = $message;
847
                    }
848
                }
849 7
                ksort($merged);
850 7
                sort($notTranslatedYet);
851
852
                // collect not yet translated messages
853 7
                foreach ($notTranslatedYet as $message) {
854 6
                    $todos[$category . chr(4) . $message] = '';
855
                }
856
857
                // add obsolete unused messages
858 7
                foreach ($existingMessages as $message => $translation) {
859 7
                    if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
860 3
                        if (!$markUnused || (!empty($translation) && (substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@'))) {
861 2
                            $todos[$category . chr(4) . $message] = $translation;
862
                        } else {
863 1
                            $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
864
                        }
865
                    }
866
                }
867
868 7
                $merged = array_merge($merged, $todos);
869 7
                if ($sort) {
870 1
                    ksort($merged);
871
                }
872
873 7
                if ($overwrite === false) {
874 7
                    $file .= '.merged';
875
                }
876
            } else {
877 10
                sort($msgs);
878 10
                foreach ($msgs as $message) {
879 10
                    $merged[$category . chr(4) . $message] = '';
880
                }
881 10
                ksort($merged);
882
            }
883 16
            $this->stdout("Category \"$category\" merged.\n");
884 16
            $hasSomethingToWrite = true;
885
        }
886 16
        if ($hasSomethingToWrite) {
887 16
            $poFile->save($file, $merged);
888 16
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
889
        } else {
890 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
891
        }
892 16
    }
893
894
    /**
895
     * Writes messages into POT file.
896
     *
897
     * @param array $messages
898
     * @param string $dirName name of the directory to write to
899
     * @param string $catalog message catalog
900
     * @since 2.0.6
901
     */
902
    protected function saveMessagesToPOT($messages, $dirName, $catalog)
903
    {
904
        $file = str_replace('\\', '/', "$dirName/$catalog.pot");
905
        FileHelper::createDirectory(dirname($file));
906
        $this->stdout("Saving messages to $file...\n");
907
908
        $poFile = new GettextPoFile();
909
910
        $merged = [];
911
912
        $hasSomethingToWrite = false;
913
        foreach ($messages as $category => $msgs) {
914
            $msgs = array_values(array_unique($msgs));
915
916
            sort($msgs);
917
            foreach ($msgs as $message) {
918
                $merged[$category . chr(4) . $message] = '';
919
            }
920
            $this->stdout("Category \"$category\" merged.\n");
921
            $hasSomethingToWrite = true;
922
        }
923
        if ($hasSomethingToWrite) {
924
            ksort($merged);
925
            $poFile->save($file, $merged);
926
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
927
        } else {
928
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
929
        }
930
    }
931
932 7
    private function deleteUnusedPhpMessageFiles($existingCategories, $dirName)
933
    {
934 7
        $messageFiles = FileHelper::findFiles($dirName);
935 7
        foreach ($messageFiles as $messageFile) {
936 7
            $categoryFileName = str_replace($dirName, '', $messageFile);
937 7
            $categoryFileName = ltrim($categoryFileName, DIRECTORY_SEPARATOR);
938 7
            $category = preg_replace('#\.php$#', '', $categoryFileName);
939 7
            $category = str_replace(DIRECTORY_SEPARATOR, '/', $category);
940
941 7
            if (!in_array($category, $existingCategories, true)) {
942 3
                unlink($messageFile);
943
            }
944
        }
945 7
    }
946
947
    /**
948
     * @param string $configFile
949
     * @throws Exception If configuration file does not exists.
950
     * @since 2.0.13
951
     */
952 62
    protected function initConfig($configFile)
953
    {
954 62
        $configFileContent = [];
955 62
        if ($configFile !== null) {
0 ignored issues
show
introduced by
The condition $configFile !== null is always true.
Loading history...
956 62
            $configFile = Yii::getAlias($configFile);
957 62
            if (!is_file($configFile)) {
0 ignored issues
show
Bug introduced by
It seems like $configFile can also be of type false; however, parameter $filename of is_file() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

957
            if (!is_file(/** @scrutinizer ignore-type */ $configFile)) {
Loading history...
958 3
                throw new Exception("The configuration file does not exist: $configFile");
959
            }
960 59
            $configFileContent = require $configFile;
961
        }
962
963 59
        $this->config = array_merge(
964 59
            $this->getOptionValues($this->action->id),
965
            $configFileContent,
966 59
            $this->getPassedOptionValues()
967
        );
968 59
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
969 59
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
970
971 59
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
972
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
973
        }
974 59
        if (!is_dir($this->config['sourcePath'])) {
0 ignored issues
show
Bug introduced by
It seems like $this->config['sourcePath'] can also be of type false; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

974
        if (!is_dir(/** @scrutinizer ignore-type */ $this->config['sourcePath'])) {
Loading history...
975
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
976
        }
977 59
        if (empty($this->config['format']) || !in_array($this->config['format'], ['php', 'po', 'pot', 'db'])) {
978
            throw new Exception('Format should be either "php", "po", "pot" or "db".');
979
        }
980 59
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
981 45
            if (!isset($this->config['messagePath'])) {
982
                throw new Exception('The configuration file must specify "messagePath".');
983
            }
984 45
            if (!is_dir($this->config['messagePath'])) {
985
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
986
            }
987
        }
988 59
        if (empty($this->config['languages'])) {
989
            throw new Exception('Languages cannot be empty.');
990
        }
991
992 59
        if ($this->config['format'] === 'php' && $this->config['phpDocBlock'] === null) {
993
            $this->config['phpDocBlock'] = <<<DOCBLOCK
994
/**
995
 * Message translations.
996
 *
997
 * This file is automatically generated by 'yii {$this->id}/{$this->action->id}' command.
998
 * It contains the localizable messages extracted from source code.
999
 * You may modify this file by translating the extracted messages.
1000
 *
1001
 * Each array element represents the translation (value) of a message (key).
1002
 * If the value is empty, the message is considered as not translated.
1003
 * Messages that no longer need translation will have their translations
1004
 * enclosed between a pair of '@@' marks.
1005
 *
1006
 * Message string can be used with plural forms format. Check i18n section
1007
 * of the guide for details.
1008
 *
1009
 * NOTE: this file must be saved in UTF-8 encoding.
1010
 */
1011
DOCBLOCK;
1012
        }
1013 59
    }
1014
}
1015