Completed
Push — php71-cleanup ( 2808ea...aac729 )
by Alexander
22:20 queued 19:16
created

MessageController::saveMessagesToPHP()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 10
cts 10
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 9
nc 2
nop 6
crap 2
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 36
    public function options($actionID)
160
    {
161 36
        return array_merge(parent::options($actionID), [
162 36
            '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 33
    public function actionExtract($configFile = null)
295
    {
296 33
        $this->initConfig($configFile);
297
298 30
        $files = FileHelper::findFiles(realpath($this->config['sourcePath']), $this->config);
299
300 30
        $messages = [];
301 30
        foreach ($files as $file) {
302 30
            $messages = array_merge_recursive($messages, $this->extractMessages($file, $this->config['translator'], $this->config['ignoreCategories']));
303
        }
304
305 30
        $catalog = isset($this->config['catalog']) ? $this->config['catalog'] : 'messages';
306
307 30
        if (in_array($this->config['format'], ['php', 'po'])) {
308 21
            foreach ($this->config['languages'] as $language) {
309 21
                $dir = $this->config['messagePath'] . DIRECTORY_SEPARATOR . $language;
310 21
                if (!is_dir($dir) && !@mkdir($dir)) {
311
                    throw new Exception("Directory '{$dir}' can not be created.");
312
                }
313 21
                if ($this->config['format'] === 'po') {
314 10
                    $this->saveMessagesToPO($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $catalog, $this->config['markUnused']);
315
                } else {
316 21
                    $this->saveMessagesToPHP($messages, $dir, $this->config['overwrite'], $this->config['removeUnused'], $this->config['sort'], $this->config['markUnused']);
317
                }
318
            }
319 9
        } elseif ($this->config['format'] === 'db') {
320
            /** @var Connection $db */
321 9
            $db = Instance::ensure($this->config['db'], Connection::class);
322 9
            $sourceMessageTable = isset($this->config['sourceMessageTable']) ? $this->config['sourceMessageTable'] : '{{%source_message}}';
323 9
            $messageTable = isset($this->config['messageTable']) ? $this->config['messageTable'] : '{{%message}}';
324 9
            $this->saveMessagesToDb(
325 9
                $messages,
326 9
                $db,
327 9
                $sourceMessageTable,
328 9
                $messageTable,
329 9
                $this->config['removeUnused'],
330 9
                $this->config['languages'],
331 9
                $this->config['markUnused']
332
            );
333
        } elseif ($this->config['format'] === 'pot') {
334
            $this->saveMessagesToPOT($messages, $this->config['messagePath'], $catalog);
335
        }
336 30
    }
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 9
    protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused)
350
    {
351 9
        $currentMessages = [];
352 9
        $rows = (new Query())->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db);
353 9
        foreach ($rows as $row) {
354 8
            $currentMessages[$row['category']][$row['id']] = $row['message'];
355
        }
356
357 9
        $currentLanguages = [];
358 9
        $rows = (new Query())->select(['language'])->from($messageTable)->groupBy('language')->all($db);
359 9
        foreach ($rows as $row) {
360 8
            $currentLanguages[] = $row['language'];
361
        }
362 9
        $missingLanguages = [];
363 9
        if (!empty($currentLanguages)) {
364 8
            $missingLanguages = array_diff($languages, $currentLanguages);
365
        }
366
367 9
        $new = [];
368 9
        $obsolete = [];
369
370 9
        foreach ($messages as $category => $msgs) {
371 9
            $msgs = array_unique($msgs);
372
373 9
            if (isset($currentMessages[$category])) {
374 6
                $new[$category] = array_diff($msgs, $currentMessages[$category]);
375 6
                $obsolete += array_diff($currentMessages[$category], $msgs);
376
            } else {
377 9
                $new[$category] = $msgs;
378
            }
379
        }
380
381 9
        foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) {
382 6
            $obsolete += $currentMessages[$category];
383
        }
384
385 9
        if (!$removeUnused) {
386 8
            foreach ($obsolete as $pk => $msg) {
387 5
                if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') {
388 5
                    unset($obsolete[$pk]);
389
                }
390
            }
391
        }
392
393 9
        $obsolete = array_keys($obsolete);
394 9
        $this->stdout('Inserting new messages...');
395 9
        $savedFlag = false;
396
397 9
        foreach ($new as $category => $msgs) {
398 9
            foreach ($msgs as $msg) {
399 8
                $savedFlag = true;
400 8
                $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]);
401 8
                foreach ($languages as $language) {
402 8
                    $db->createCommand()
403 8
                       ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language])
404 9
                       ->execute();
405
                }
406
            }
407
        }
408
409 9
        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 9
        $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n");
428 9
        $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...');
429
430 9
        if (empty($obsolete)) {
431 6
            $this->stdout("Nothing obsoleted...skipped.\n");
432 6
            return;
433
        }
434
435 6
        if ($removeUnused) {
436 1
            $db->createCommand()
437 1
               ->delete($sourceMessageTable, ['in', 'id', $obsolete])
438 1
               ->execute();
439 1
            $this->stdout("deleted.\n");
440 5
        } elseif ($markUnused) {
441 5
            $rows = (new Query())
442 5
                ->select(['id', 'message'])
443 5
                ->from($sourceMessageTable)
444 5
                ->where(['in', 'id', $obsolete])
445 5
                ->all($db);
446
447 5
            foreach ($rows as $row) {
448 5
                $db->createCommand()->update(
449 5
                    $sourceMessageTable,
450 5
                    ['message' => '@@' . $row['message'] . '@@'],
451 5
                    ['id' => $row['id']]
452 5
                )->execute();
453
            }
454 5
            $this->stdout("updated.\n");
455
        } else {
456
            $this->stdout("kept untouched.\n");
457
        }
458 6
    }
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 30
    protected function extractMessages($fileName, $translator, $ignoreCategories = [])
470
    {
471 30
        $this->stdout('Extracting messages from ');
472 30
        $this->stdout($fileName, Console::FG_CYAN);
473 30
        $this->stdout("...\n");
474
475 30
        $subject = file_get_contents($fileName);
476 30
        $messages = [];
477 30
        $tokens = token_get_all($subject);
478 30
        foreach ((array) $translator as $currentTranslator) {
479 30
            $translatorTokens = token_get_all('<?php ' . $currentTranslator);
480 30
            array_shift($translatorTokens);
481 30
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($tokens, $translatorTokens, $ignoreCategories));
482
        }
483
484 30
        $this->stdout("\n");
485
486 30
        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 30
    protected function extractMessagesFromTokens(array $tokens, array $translatorTokens, array $ignoreCategories)
497
    {
498 30
        $messages = [];
499 30
        $translatorTokensCount = count($translatorTokens);
500 30
        $matchedTokensCount = 0;
501 30
        $buffer = [];
502 30
        $pendingParenthesisCount = 0;
503
504 30
        foreach ($tokens as $token) {
505
            // finding out translator call
506 30
            if ($matchedTokensCount < $translatorTokensCount) {
507 30
                if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
508 30
                    $matchedTokensCount++;
509
                } else {
510 30
                    $matchedTokensCount = 0;
511
                }
512 30
            } elseif ($matchedTokensCount === $translatorTokensCount) {
513
                // translator found
514
515
                // end of function call
516 30
                if ($this->tokensEqual(')', $token)) {
517 30
                    $pendingParenthesisCount--;
518
519 30
                    if ($pendingParenthesisCount === 0) {
520
                        // end of translator call or end of something that we can't extract
521 30
                        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 30
                            $category = stripcslashes($buffer[0][1]);
524 30
                            $category = mb_substr($category, 1, -1);
525
526 30
                            if (!$this->isCategoryIgnored($category, $ignoreCategories)) {
527 30
                                $fullMessage = mb_substr($buffer[2][1], 1, -1);
528 30
                                $i = 3;
529 30
                                while ($i < count($buffer) - 1 && !is_array($buffer[$i]) && $buffer[$i] === '.') {
530
                                    $fullMessage .= mb_substr($buffer[$i + 1][1], 1, -1);
531
                                    $i += 2;
532
                                }
533
534 30
                                $message = stripcslashes($fullMessage);
535 30
                                $messages[$category][] = $message;
536
                            }
537
538 30
                            $nestedTokens = array_slice($buffer, 3);
539 30
                            if (count($nestedTokens) > $translatorTokensCount) {
540
                                // search for possible nested translator calls
541 30
                                $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 30
                        $matchedTokensCount = 0;
552 30
                        $pendingParenthesisCount = 0;
553 30
                        $buffer = [];
554
                    } else {
555 30
                        $buffer[] = $token;
556
                    }
557 30
                } elseif ($this->tokensEqual('(', $token)) {
558
                    // count beginning of function call, skipping translator beginning
559 30
                    if ($pendingParenthesisCount > 0) {
560 3
                        $buffer[] = $token;
561
                    }
562 30
                    $pendingParenthesisCount++;
563 30
                } elseif (isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
564
                    // ignore comments and whitespaces
565 30
                    $buffer[] = $token;
566
                }
567
            }
568
        }
569
570 30
        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 30
    protected function isCategoryIgnored($category, array $ignoreCategories)
586
    {
587 30
        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 30
        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 30
    protected function tokensEqual($a, $b)
610
    {
611 30
        if (is_string($a) && is_string($b)) {
612 30
            return $a === $b;
613
        }
614 30
        if (isset($a[0], $a[1], $b[0], $b[1])) {
615 30
            return $a[0] === $b[0] && $a[1] == $b[1];
616
        }
617 30
        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 11
    protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort, $markUnused)
648
    {
649 11
        foreach ($messages as $category => $msgs) {
650 11
            $file = str_replace('\\', '/', "$dirName/$category.php");
651 11
            $path = dirname($file);
652 11
            FileHelper::createDirectory($path);
653 11
            $msgs = array_values(array_unique($msgs));
654 11
            $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
655 11
            $this->stdout("Saving messages to $coloredFileName...\n");
656 11
            $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category, $markUnused);
657
        }
658 11
    }
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 11
    protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category, $markUnused)
673
    {
674 11
        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 8
            $merged = [];
719 8
            foreach ($messages as $message) {
720 8
                $merged[$message] = '';
721
            }
722 8
            ksort($merged);
723
        }
724
725 11
        $array = VarDumper::export($merged);
726
        $content = <<<EOD
727
<?php
728 11
{$this->config['phpFileHeader']}{$this->config['phpDocBlock']}
729 11
return $array;
730
731
EOD;
732
733 11
        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 11
        $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
739 11
        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 10
    protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog, $markUnused)
754
    {
755 10
        $file = str_replace('\\', '/', "$dirName/$catalog.po");
756 10
        FileHelper::createDirectory(dirname($file));
757 10
        $this->stdout("Saving messages to $file...\n");
758
759 10
        $poFile = new GettextPoFile();
760
761 10
        $merged = [];
762 10
        $todos = [];
763
764 10
        $hasSomethingToWrite = false;
765 10
        foreach ($messages as $category => $msgs) {
766 10
            $notTranslatedYet = [];
767 10
            $msgs = array_values(array_unique($msgs));
768
769 10
            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 7
                sort($msgs);
822 7
                foreach ($msgs as $message) {
823 7
                    $merged[$category . chr(4) . $message] = '';
824
                }
825 7
                ksort($merged);
826
            }
827 10
            $this->stdout("Category \"$category\" merged.\n");
828 10
            $hasSomethingToWrite = true;
829
        }
830 10
        if ($hasSomethingToWrite) {
831 10
            $poFile->save($file, $merged);
832 10
            $this->stdout("Translation saved.\n", Console::FG_GREEN);
833
        } else {
834 3
            $this->stdout("Nothing to save.\n", Console::FG_GREEN);
835
        }
836 10
    }
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 33
    protected function initConfig($configFile)
882
    {
883 33
        $configFileContent = [];
884 33
        if ($configFile !== null) {
885 33
            $configFile = Yii::getAlias($configFile);
886 33
            if (!is_file($configFile)) {
887 3
                throw new Exception("The configuration file does not exist: $configFile");
888
            }
889 30
            $configFileContent = require $configFile;
890
        }
891
892 30
        $this->config = array_merge(
893 30
            $this->getOptionValues($this->action->id),
894 30
            $configFileContent,
895 30
            $this->getPassedOptionValues()
896
        );
897 30
        $this->config['sourcePath'] = Yii::getAlias($this->config['sourcePath']);
898 30
        $this->config['messagePath'] = Yii::getAlias($this->config['messagePath']);
899
900 30
        if (!isset($this->config['sourcePath'], $this->config['languages'])) {
901
            throw new Exception('The configuration file must specify "sourcePath" and "languages".');
902
        }
903 30
        if (!is_dir($this->config['sourcePath'])) {
904
            throw new Exception("The source path {$this->config['sourcePath']} is not a valid directory.");
905
        }
906 30
        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 30
        if (in_array($this->config['format'], ['php', 'po', 'pot'])) {
910 21
            if (!isset($this->config['messagePath'])) {
911
                throw new Exception('The configuration file must specify "messagePath".');
912
            }
913 21
            if (!is_dir($this->config['messagePath'])) {
914
                throw new Exception("The message path {$this->config['messagePath']} is not a valid directory.");
915
            }
916
        }
917 30
        if (empty($this->config['languages'])) {
918
            throw new Exception('Languages cannot be empty.');
919
        }
920
921 30
        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 30
    }
943
}
944