diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 75ef0e4ff3..4c0f27fa73 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -8,6 +8,9 @@ Yii Framework 2 Change Log 2.0.11 February 01, 2017 +- Bug #11502: Fixed `yii\console\controllers\MessageController` to properly populate missing languages in case of extraction with "db" format (bizley) + +2.0.11 under development ------------------------ - Bug #4113: Error page stacktrace was generating links to private methods which are not part of the API docs (samdark) diff --git a/framework/console/controllers/MessageController.php b/framework/console/controllers/MessageController.php index bd47cc00ba..a58d34af12 100644 --- a/framework/console/controllers/MessageController.php +++ b/framework/console/controllers/MessageController.php @@ -8,8 +8,10 @@ namespace yii\console\controllers; use Yii; -use yii\console\Controller; use yii\console\Exception; +use yii\db\Connection; +use yii\db\Query; +use yii\di\Instance; use yii\helpers\Console; use yii\helpers\FileHelper; use yii\helpers\VarDumper; @@ -35,7 +37,7 @@ use yii\i18n\GettextPoFile; * @author Qiang Xue * @since 2.0 */ -class MessageController extends Controller +class MessageController extends \yii\console\Controller { /** * @var string controller default action ID. @@ -105,7 +107,7 @@ class MessageController extends Controller */ public $only = ['*.php']; /** - * @var string generated file format. Can be "php", "db" or "po". + * @var string generated file format. Can be "php", "db", "po" or "pot". */ public $format = 'php'; /** @@ -217,13 +219,13 @@ return $array; EOD; - if (file_put_contents($filePath, $content) !== false) { - $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN); - return self::EXIT_CODE_NORMAL; - } else { + if (file_put_contents($filePath, $content) === false) { $this->stdout("Configuration file was NOT created: '{$filePath}'.\n\n", Console::FG_RED); return self::EXIT_CODE_ERROR; } + + $this->stdout("Configuration file created: '{$filePath}'.\n\n", Console::FG_GREEN); + return self::EXIT_CODE_NORMAL; } /** @@ -240,18 +242,20 @@ EOD; public function actionConfigTemplate($filePath) { $filePath = Yii::getAlias($filePath); + if (file_exists($filePath)) { if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) { return self::EXIT_CODE_NORMAL; } } - if (copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) { - $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN); - return self::EXIT_CODE_NORMAL; - } else { + + if (!copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath)) { $this->stdout("Configuration file template was NOT created at '{$filePath}'.\n\n", Console::FG_RED); return self::EXIT_CODE_ERROR; } + + $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN); + return self::EXIT_CODE_NORMAL; } /** @@ -272,9 +276,8 @@ EOD; $configFile = Yii::getAlias($configFile); if (!is_file($configFile)) { throw new Exception("The configuration file does not exist: $configFile"); - } else { - $configFileContent = require($configFile); } + $configFileContent = require($configFile); } $config = array_merge( @@ -297,7 +300,8 @@ EOD; if (in_array($config['format'], ['php', 'po', 'pot'])) { if (!isset($config['messagePath'])) { throw new Exception('The configuration file must specify "messagePath".'); - } elseif (!is_dir($config['messagePath'])) { + } + if (!is_dir($config['messagePath'])) { throw new Exception("The message path {$config['messagePath']} is not a valid directory."); } } @@ -311,24 +315,24 @@ EOD; foreach ($files as $file) { $messages = array_merge_recursive($messages, $this->extractMessages($file, $config['translator'], $config['ignoreCategories'])); } + + $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages'; + if (in_array($config['format'], ['php', 'po'])) { foreach ($config['languages'] as $language) { $dir = $config['messagePath'] . DIRECTORY_SEPARATOR . $language; - if (!is_dir($dir)) { - @mkdir($dir); + if (!is_dir($dir) && !@mkdir($dir)) { + throw new Exception("Directory '{$dir}' can not be created."); } if ($config['format'] === 'po') { - $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages'; $this->saveMessagesToPO($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $catalog, $config['markUnused']); } else { $this->saveMessagesToPHP($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $config['markUnused']); } } } elseif ($config['format'] === 'db') { - $db = \Yii::$app->get(isset($config['db']) ? $config['db'] : 'db'); - if (!$db instanceof \yii\db\Connection) { - throw new Exception('The "db" option must refer to a valid database application component.'); - } + /** @var Connection $db */ + $db = Instance::ensure($config['db'], Connection::className()); $sourceMessageTable = isset($config['sourceMessageTable']) ? $config['sourceMessageTable'] : '{{%source_message}}'; $messageTable = isset($config['messageTable']) ? $config['messageTable'] : '{{%message}}'; $this->saveMessagesToDb( @@ -341,7 +345,6 @@ EOD; $config['markUnused'] ); } elseif ($config['format'] === 'pot') { - $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages'; $this->saveMessagesToPOT($messages, $config['messagePath'], $catalog); } } @@ -350,7 +353,7 @@ EOD; * Saves messages to database * * @param array $messages - * @param \yii\db\Connection $db + * @param Connection $db * @param string $sourceMessageTable * @param string $messageTable * @param bool $removeUnused @@ -359,11 +362,20 @@ EOD; */ protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages, $markUnused) { - $q = new \yii\db\Query; - $current = []; + $currentMessages = []; + $rows = (new Query)->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db); + foreach ($rows as $row) { + $currentMessages[$row['category']][$row['id']] = $row['message']; + } - foreach ($q->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db) as $row) { - $current[$row['category']][$row['id']] = $row['message']; + $currentLanguages = []; + $rows = (new Query)->select(['language'])->from($messageTable)->groupBy('language')->all($db); + foreach ($rows as $row) { + $currentLanguages[] = $row['language']; + } + $missingLanguages = []; + if (!empty($currentLanguages)) { + $missingLanguages = array_diff($languages, $currentLanguages); } $new = []; @@ -372,21 +384,21 @@ EOD; foreach ($messages as $category => $msgs) { $msgs = array_unique($msgs); - if (isset($current[$category])) { - $new[$category] = array_diff($msgs, $current[$category]); - $obsolete += array_diff($current[$category], $msgs); + if (isset($currentMessages[$category])) { + $new[$category] = array_diff($msgs, $currentMessages[$category]); + $obsolete += array_diff($currentMessages[$category], $msgs); } else { $new[$category] = $msgs; } } - foreach (array_diff(array_keys($current), array_keys($messages)) as $category) { - $obsolete += $current[$category]; + foreach (array_diff(array_keys($currentMessages), array_keys($messages)) as $category) { + $obsolete += $currentMessages[$category]; } if (!$removeUnused) { - foreach ($obsolete as $pk => $m) { - if (mb_substr($m, 0, 2) === '@@' && mb_substr($m, -2) === '@@') { + foreach ($obsolete as $pk => $msg) { + if (mb_substr($msg, 0, 2) === '@@' && mb_substr($msg, -2) === '@@') { unset($obsolete[$pk]); } } @@ -397,9 +409,9 @@ EOD; $savedFlag = false; foreach ($new as $category => $msgs) { - foreach ($msgs as $m) { + foreach ($msgs as $msg) { $savedFlag = true; - $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $m]); + $lastPk = $db->schema->insert($sourceMessageTable, ['category' => $category, 'message' => $msg]); foreach ($languages as $language) { $db->createCommand() ->insert($messageTable, ['id' => $lastPk['id'], 'language' => $language]) @@ -408,7 +420,25 @@ EOD; } } - $this->stdout($savedFlag ? "saved.\n" : "Nothing new...skipped.\n"); + if (!empty($missingLanguages)) { + $updatedMessages = []; + $rows = (new Query)->select(['id', 'category', 'message'])->from($sourceMessageTable)->all($db); + foreach ($rows as $row) { + $updatedMessages[$row['category']][$row['id']] = $row['message']; + } + foreach ($updatedMessages as $category => $msgs) { + foreach ($msgs as $id => $msg) { + $savedFlag = true; + foreach ($missingLanguages as $language) { + $db->createCommand() + ->insert($messageTable, ['id' => $id, 'language' => $language]) + ->execute(); + } + } + } + } + + $this->stdout($savedFlag ? "saved.\n" : "Nothing to save.\n"); $this->stdout($removeUnused ? 'Deleting obsoleted messages...' : 'Updating obsoleted messages...'); if (empty($obsolete)) { @@ -554,22 +584,18 @@ EOD; */ protected function isCategoryIgnored($category, array $ignoreCategories) { - $result = false; - if (!empty($ignoreCategories)) { if (in_array($category, $ignoreCategories, true)) { - $result = true; - } else { - foreach ($ignoreCategories as $pattern) { - if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) { - $result = true; - break; - } + return true; + } + foreach ($ignoreCategories as $pattern) { + if (strpos($pattern, '*') > 0 && strpos($category, rtrim($pattern, '*')) === 0) { + return true; } } } - return $result; + return false; } /** @@ -584,7 +610,8 @@ EOD; { if (is_string($a) && is_string($b)) { return $a === $b; - } elseif (isset($a[0], $a[1], $b[0], $b[1])) { + } + if (isset($a[0], $a[1], $b[0], $b[1])) { return $a[0] === $b[0] && $a[1] == $b[1]; } return false; @@ -695,7 +722,6 @@ EOD; ksort($merged); } - $array = VarDumper::export($merged); $content = <<stdout("Translation saved.\n\n", Console::FG_GREEN); - return self::EXIT_CODE_NORMAL; - } else { + if (file_put_contents($fileName, $content) === false) { $this->stdout("Translation was NOT saved.\n\n", Console::FG_RED); return self::EXIT_CODE_ERROR; } + + $this->stdout("Translation saved.\n\n", Console::FG_GREEN); + return self::EXIT_CODE_NORMAL; } /** @@ -748,7 +774,6 @@ EOD; $poFile = new GettextPoFile(); - $merged = []; $todos = []; diff --git a/tests/framework/console/controllers/BaseMessageControllerTest.php b/tests/framework/console/controllers/BaseMessageControllerTest.php index 6e309bf6ee..885653a695 100644 --- a/tests/framework/console/controllers/BaseMessageControllerTest.php +++ b/tests/framework/console/controllers/BaseMessageControllerTest.php @@ -402,6 +402,32 @@ abstract class BaseMessageControllerTest extends TestCase $this->assertArrayHasKey($mainMessage, $messages, "\"$mainMessage\" is missing in translation file. Command output:\n\n" . $out); $this->assertArrayHasKey($nestedMessage, $messages, "\"$nestedMessage\" is missing in translation file. Command output:\n\n" . $out); } + + /** + * @depends testCreateTranslation + * + * @see https://github.com/yiisoft/yii2/issues/11502 + */ + public function testMissingLanguage() + { + $category = 'multiLangCategory'; + $mainMessage = 'multiLangMessage'; + $sourceFileContent = "Yii::t('{$category}', '{$mainMessage}');"; + $this->createSourceFile($sourceFileContent); + + $this->saveConfigFile($this->getConfig()); + $out = $this->runMessageControllerAction('extract', [$this->configFileName]); + + $secondLanguage = 'pl'; + $this->saveConfigFile($this->getConfig(['languages' => [$this->language, $secondLanguage]])); + $out .= $this->runMessageControllerAction('extract', [$this->configFileName]); + + $firstLanguage = $this->language; + $this->language = $secondLanguage; + $messages = $this->loadMessages($category); + $this->language = $firstLanguage; + $this->assertArrayHasKey($mainMessage, $messages, "\"$mainMessage\" for language \"$secondLanguage\" is missing in translation file. Command output:\n\n" . $out); + } } class MessageControllerMock extends MessageController diff --git a/tests/framework/console/controllers/DbMessageControllerTest.php b/tests/framework/console/controllers/DbMessageControllerTest.php new file mode 100644 index 0000000000..f426532820 --- /dev/null +++ b/tests/framework/console/controllers/DbMessageControllerTest.php @@ -0,0 +1,179 @@ + 'Migrator', + 'basePath' => '@yiiunit', + 'controllerMap' => [ + 'migrate' => EchoMigrateController::className(), + ], + 'components' => [ + 'db' => static::getConnection(), + ], + ]); + } + + ob_start(); + $result = Yii::$app->runAction($route, $params); + echo "Result is " . $result; + if ($result !== \yii\console\Controller::EXIT_CODE_NORMAL) { + ob_end_flush(); + } else { + ob_end_clean(); + } + } + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + $databases = static::getParam('databases'); + static::$database = $databases[static::$driverName]; + $pdo_database = 'pdo_' . static::$driverName; + + if (!extension_loaded('pdo') || !extension_loaded($pdo_database)) { + static::markTestSkipped('pdo and ' . $pdo_database . ' extension are required.'); + } + + static::runConsoleAction('migrate/up', ['migrationPath' => '@yii/i18n/migrations/', 'interactive' => false]); + } + + public static function tearDownAfterClass() + { + static::runConsoleAction('migrate/down', ['migrationPath' => '@yii/i18n/migrations/', 'interactive' => false]); + if (static::$db) { + static::$db->close(); + } + Yii::$app = null; + parent::tearDownAfterClass(); + } + + public function tearDown() + { + parent::tearDown(); + Yii::$app = null; + } + + /** + * @throws \yii\base\InvalidParamException + * @throws \yii\db\Exception + * @throws \yii\base\InvalidConfigException + * @return \yii\db\Connection + */ + public static function getConnection() + { + if (static::$db == null) { + $db = new Connection; + $db->dsn = static::$database['dsn']; + if (isset(static::$database['username'])) { + $db->username = static::$database['username']; + $db->password = static::$database['password']; + } + if (isset(static::$database['attributes'])) { + $db->attributes = static::$database['attributes']; + } + if (!$db->isActive) { + $db->open(); + } + static::$db = $db; + } + return static::$db; + } + + /** + * @inheritdoc + */ + protected function getDefaultConfig() + { + return [ + 'format' => 'db', + 'languages' => [$this->language], + 'sourcePath' => $this->sourcePath, + 'overwrite' => true, + 'db' => static::$db + ]; + } + + /** + * @inheritdoc + */ + protected function saveMessages($messages, $category) + { + static::$db->createCommand()->checkIntegrity(false, '', 'message')->execute(); + static::$db->createCommand()->truncateTable('message')->execute(); + static::$db->createCommand()->truncateTable('source_message')->execute(); + static::$db->createCommand()->checkIntegrity(true, '', 'message')->execute(); + foreach ($messages as $source => $translation) { + $lastPk = static::$db->schema->insert('source_message', [ + 'category' => $category, + 'message' => $source + ]); + static::$db->createCommand()->insert('message', [ + 'id' => $lastPk['id'], + 'language' => $this->language, + 'translation' => $translation + ])->execute(); + } + } + + /** + * @inheritdoc + */ + protected function loadMessages($category) + { + return \yii\helpers\ArrayHelper::map((new \yii\db\Query()) + ->select(['message' => 't1.message', 'translation' => 't2.translation']) + ->from(['t1' => 'source_message', 't2' => 'message']) + ->where([ + 't1.id' => new \yii\db\Expression('[[t2.id]]'), + 't1.category' => $category, + 't2.language' => $this->language, + ])->all(static::$db), 'message', 'translation'); + } + + // DbMessage tests variants: + + /** + * Source is marked instead of translation. + * @depends testMerge + */ + public function testMarkObsoleteMessages() + { + $category = 'category'; + + $obsoleteMessage = 'obsolete message'; + $obsoleteTranslation = 'obsolete translation'; + $this->saveMessages([$obsoleteMessage => $obsoleteTranslation], $category); + + $sourceFileContent = "Yii::t('{$category}', 'any new message');"; + $this->createSourceFile($sourceFileContent); + + $this->saveConfigFile($this->getConfig(['removeUnused' => false])); + $out = $this->runMessageControllerAction('extract', [$this->configFileName]); + + $obsoleteMessage = '@@obsolete message@@'; + + $messages = $this->loadMessages($category); + + $this->assertArrayHasKey($obsoleteMessage, $messages, "Obsolete message should not be removed. Command output:\n\n" . $out); + $this->assertEquals($obsoleteTranslation, $messages[$obsoleteMessage], "Obsolete message was not marked properly. Command output:\n\n" . $out); + } +} \ No newline at end of file