Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

550 lines
21KB

  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. namespace yii\console\controllers;
  8. use Yii;
  9. use yii\console\Controller;
  10. use yii\console\Exception;
  11. use yii\helpers\Console;
  12. use yii\helpers\FileHelper;
  13. use yii\helpers\VarDumper;
  14. use yii\i18n\GettextPoFile;
  15. /**
  16. * Extracts messages to be translated from source files.
  17. *
  18. * The extracted messages can be saved the following depending on `format`
  19. * setting in config file:
  20. *
  21. * - PHP message source files.
  22. * - ".po" files.
  23. * - Database.
  24. *
  25. * Usage:
  26. * 1. Create a configuration file using the 'message/config' command:
  27. * yii message/config /path/to/myapp/messages/config.php
  28. * 2. Edit the created config file, adjusting it for your web application needs.
  29. * 3. Run the 'message/extract' command, using created config:
  30. * yii message /path/to/myapp/messages/config.php
  31. *
  32. * @author Qiang Xue <qiang.xue@gmail.com>
  33. * @since 2.0
  34. */
  35. class MessageController extends Controller
  36. {
  37. /**
  38. * @var string controller default action ID.
  39. */
  40. public $defaultAction = 'extract';
  41. /**
  42. * Creates a configuration file for the "extract" command.
  43. *
  44. * The generated configuration file contains detailed instructions on
  45. * how to customize it to fit for your needs. After customization,
  46. * you may use this configuration file with the "extract" command.
  47. *
  48. * @param string $filePath output file name or alias.
  49. * @return integer CLI exit code
  50. * @throws Exception on failure.
  51. */
  52. public function actionConfig($filePath)
  53. {
  54. $filePath = Yii::getAlias($filePath);
  55. if (file_exists($filePath)) {
  56. if (!$this->confirm("File '{$filePath}' already exists. Do you wish to overwrite it?")) {
  57. return self::EXIT_CODE_NORMAL;
  58. }
  59. }
  60. copy(Yii::getAlias('@yii/views/messageConfig.php'), $filePath);
  61. $this->stdout("Configuration file template created at '{$filePath}'.\n\n", Console::FG_GREEN);
  62. return self::EXIT_CODE_NORMAL;
  63. }
  64. /**
  65. * Extracts messages to be translated from source code.
  66. *
  67. * This command will search through source code files and extract
  68. * messages that need to be translated in different languages.
  69. *
  70. * @param string $configFile the path or alias of the configuration file.
  71. * You may use the "yii message/config" command to generate
  72. * this file and then customize it for your needs.
  73. * @throws Exception on failure.
  74. */
  75. public function actionExtract($configFile)
  76. {
  77. $configFile = Yii::getAlias($configFile);
  78. if (!is_file($configFile)) {
  79. throw new Exception("The configuration file does not exist: $configFile");
  80. }
  81. $config = array_merge([
  82. 'translator' => 'Yii::t',
  83. 'overwrite' => false,
  84. 'removeUnused' => false,
  85. 'sort' => false,
  86. 'format' => 'php',
  87. ], require($configFile));
  88. if (!isset($config['sourcePath'], $config['languages'])) {
  89. throw new Exception('The configuration file must specify "sourcePath" and "languages".');
  90. }
  91. if (!is_dir($config['sourcePath'])) {
  92. throw new Exception("The source path {$config['sourcePath']} is not a valid directory.");
  93. }
  94. if (empty($config['format']) || !in_array($config['format'], ['php', 'po', 'db'])) {
  95. throw new Exception('Format should be either "php", "po" or "db".');
  96. }
  97. if (in_array($config['format'], ['php', 'po'])) {
  98. if (!isset($config['messagePath'])) {
  99. throw new Exception('The configuration file must specify "messagePath".');
  100. } elseif (!is_dir($config['messagePath'])) {
  101. throw new Exception("The message path {$config['messagePath']} is not a valid directory.");
  102. }
  103. }
  104. if (empty($config['languages'])) {
  105. throw new Exception("Languages cannot be empty.");
  106. }
  107. $files = FileHelper::findFiles(realpath($config['sourcePath']), $config);
  108. $messages = [];
  109. foreach ($files as $file) {
  110. $messages = array_merge_recursive($messages, $this->extractMessages($file, $config['translator']));
  111. }
  112. if (in_array($config['format'], ['php', 'po'])) {
  113. foreach ($config['languages'] as $language) {
  114. $dir = $config['messagePath'] . DIRECTORY_SEPARATOR . $language;
  115. if (!is_dir($dir)) {
  116. @mkdir($dir);
  117. }
  118. if ($config['format'] === 'po') {
  119. $catalog = isset($config['catalog']) ? $config['catalog'] : 'messages';
  120. $this->saveMessagesToPO($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort'], $catalog);
  121. } else {
  122. $this->saveMessagesToPHP($messages, $dir, $config['overwrite'], $config['removeUnused'], $config['sort']);
  123. }
  124. }
  125. } elseif ($config['format'] === 'db') {
  126. $db = \Yii::$app->get(isset($config['db']) ? $config['db'] : 'db');
  127. if (!$db instanceof \yii\db\Connection) {
  128. throw new Exception('The "db" option must refer to a valid database application component.');
  129. }
  130. $sourceMessageTable = isset($config['sourceMessageTable']) ? $config['sourceMessageTable'] : '{{%source_message}}';
  131. $messageTable = isset($config['messageTable']) ? $config['messageTable'] : '{{%message}}';
  132. $this->saveMessagesToDb(
  133. $messages,
  134. $db,
  135. $sourceMessageTable,
  136. $messageTable,
  137. $config['removeUnused'],
  138. $config['languages']
  139. );
  140. }
  141. }
  142. /**
  143. * Saves messages to database
  144. *
  145. * @param array $messages
  146. * @param \yii\db\Connection $db
  147. * @param string $sourceMessageTable
  148. * @param string $messageTable
  149. * @param boolean $removeUnused
  150. * @param array $languages
  151. */
  152. protected function saveMessagesToDb($messages, $db, $sourceMessageTable, $messageTable, $removeUnused, $languages)
  153. {
  154. $q = new \yii\db\Query;
  155. $current = [];
  156. foreach ($q->select(['id', 'category', 'message'])->from($sourceMessageTable)->all() as $row) {
  157. $current[$row['category']][$row['id']] = $row['message'];
  158. }
  159. $new = [];
  160. $obsolete = [];
  161. foreach ($messages as $category => $msgs) {
  162. $msgs = array_unique($msgs);
  163. if (isset($current[$category])) {
  164. $new[$category] = array_diff($msgs, $current[$category]);
  165. $obsolete += array_diff($current[$category], $msgs);
  166. } else {
  167. $new[$category] = $msgs;
  168. }
  169. }
  170. foreach (array_diff(array_keys($current), array_keys($messages)) as $category) {
  171. $obsolete += $current[$category];
  172. }
  173. if (!$removeUnused) {
  174. foreach ($obsolete as $pk => $m) {
  175. if (mb_substr($m, 0, 2) === '@@' && mb_substr($m, -2) === '@@') {
  176. unset($obsolete[$pk]);
  177. }
  178. }
  179. }
  180. $obsolete = array_keys($obsolete);
  181. $this->stdout("Inserting new messages...");
  182. $savedFlag = false;
  183. foreach ($new as $category => $msgs) {
  184. foreach ($msgs as $m) {
  185. $savedFlag = true;
  186. $db->createCommand()
  187. ->insert($sourceMessageTable, ['category' => $category, 'message' => $m])->execute();
  188. $lastID = $db->getLastInsertID();
  189. foreach ($languages as $language) {
  190. $db->createCommand()
  191. ->insert($messageTable, ['id' => $lastID, 'language' => $language])->execute();
  192. }
  193. }
  194. }
  195. $this->stdout($savedFlag ? "saved.\n" : "Nothing new...skipped.\n");
  196. $this->stdout($removeUnused ? "Deleting obsoleted messages..." : "Updating obsoleted messages...");
  197. if (empty($obsolete)) {
  198. $this->stdout("Nothing obsoleted...skipped.\n");
  199. } else {
  200. if ($removeUnused) {
  201. $db->createCommand()
  202. ->delete($sourceMessageTable, ['in', 'id', $obsolete])->execute();
  203. $this->stdout("deleted.\n");
  204. } else {
  205. $db->createCommand()
  206. ->update(
  207. $sourceMessageTable,
  208. ['message' => new \yii\db\Expression("CONCAT('@@',message,'@@')")],
  209. ['in', 'id', $obsolete]
  210. )->execute();
  211. $this->stdout("updated.\n");
  212. }
  213. }
  214. }
  215. /**
  216. * Extracts messages from a file
  217. *
  218. * @param string $fileName name of the file to extract messages from
  219. * @param string $translator name of the function used to translate messages
  220. * @return array
  221. */
  222. protected function extractMessages($fileName, $translator)
  223. {
  224. $coloredFileName = Console::ansiFormat($fileName, [Console::FG_CYAN]);
  225. $this->stdout("Extracting messages from $coloredFileName...\n");
  226. $subject = file_get_contents($fileName);
  227. $messages = [];
  228. foreach ((array)$translator as $currentTranslator) {
  229. $translatorTokens = token_get_all('<?php ' . $currentTranslator);
  230. array_shift($translatorTokens);
  231. $translatorTokensCount = count($translatorTokens);
  232. $matchedTokensCount = 0;
  233. $buffer = [];
  234. $tokens = token_get_all($subject);
  235. foreach ($tokens as $token) {
  236. // finding out translator call
  237. if ($matchedTokensCount < $translatorTokensCount) {
  238. if ($this->tokensEqual($token, $translatorTokens[$matchedTokensCount])) {
  239. $matchedTokensCount++;
  240. } else {
  241. $matchedTokensCount = 0;
  242. }
  243. } elseif ($matchedTokensCount === $translatorTokensCount) {
  244. // translator found
  245. // end of translator call or end of something that we can't extract
  246. if ($this->tokensEqual(')', $token)) {
  247. 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) {
  248. // is valid call we can extract
  249. $category = stripcslashes($buffer[0][1]);
  250. $category = mb_substr($category, 1, mb_strlen($category) - 2);
  251. $message = stripcslashes($buffer[2][1]);
  252. $message = mb_substr($message, 1, mb_strlen($message) - 2);
  253. $messages[$category][] = $message;
  254. } else {
  255. // invalid call or dynamic call we can't extract
  256. $line = Console::ansiFormat($this->getLine($buffer), [Console::FG_CYAN]);
  257. $skipping = Console::ansiFormat('Skipping line', [Console::FG_YELLOW]);
  258. $this->stdout("$skipping $line. Make sure both category and message are static strings.\n");
  259. }
  260. // prepare for the next match
  261. $matchedTokensCount = 0;
  262. $buffer = [];
  263. } elseif ($token !== '(' && isset($token[0]) && !in_array($token[0], [T_WHITESPACE, T_COMMENT])) {
  264. // ignore comments, whitespaces and beginning of function call
  265. $buffer[] = $token;
  266. }
  267. }
  268. }
  269. }
  270. $this->stdout("\n");
  271. return $messages;
  272. }
  273. /**
  274. * Finds out if two PHP tokens are equal
  275. *
  276. * @param array|string $a
  277. * @param array|string $b
  278. * @return boolean
  279. * @since 2.0.1
  280. */
  281. protected function tokensEqual($a, $b)
  282. {
  283. if (is_string($a) && is_string($b)) {
  284. return $a === $b;
  285. } elseif (isset($a[0], $a[1], $b[0], $b[1])) {
  286. return $a[0] === $b[0] && $a[1] == $b[1];
  287. }
  288. return false;
  289. }
  290. /**
  291. * Finds out a line of the first non-char PHP token found
  292. *
  293. * @param array $tokens
  294. * @return int|string
  295. * @since 2.0.1
  296. */
  297. protected function getLine($tokens)
  298. {
  299. foreach ($tokens as $token) {
  300. if (isset($token[2])) {
  301. return $token[2];
  302. }
  303. }
  304. return 'unknown';
  305. }
  306. /**
  307. * Writes messages into PHP files
  308. *
  309. * @param array $messages
  310. * @param string $dirName name of the directory to write to
  311. * @param boolean $overwrite if existing file should be overwritten without backup
  312. * @param boolean $removeUnused if obsolete translations should be removed
  313. * @param boolean $sort if translations should be sorted
  314. */
  315. protected function saveMessagesToPHP($messages, $dirName, $overwrite, $removeUnused, $sort)
  316. {
  317. foreach ($messages as $category => $msgs) {
  318. $file = str_replace("\\", '/', "$dirName/$category.php");
  319. $path = dirname($file);
  320. FileHelper::createDirectory($path);
  321. $msgs = array_values(array_unique($msgs));
  322. $coloredFileName = Console::ansiFormat($file, [Console::FG_CYAN]);
  323. $this->stdout("Saving messages to $coloredFileName...\n");
  324. $this->saveMessagesCategoryToPHP($msgs, $file, $overwrite, $removeUnused, $sort, $category);
  325. }
  326. }
  327. /**
  328. * Writes category messages into PHP file
  329. *
  330. * @param array $messages
  331. * @param string $fileName name of the file to write to
  332. * @param boolean $overwrite if existing file should be overwritten without backup
  333. * @param boolean $removeUnused if obsolete translations should be removed
  334. * @param boolean $sort if translations should be sorted
  335. * @param string $category message category
  336. */
  337. protected function saveMessagesCategoryToPHP($messages, $fileName, $overwrite, $removeUnused, $sort, $category)
  338. {
  339. if (is_file($fileName)) {
  340. $existingMessages = require($fileName);
  341. sort($messages);
  342. ksort($existingMessages);
  343. if (array_keys($existingMessages) == $messages) {
  344. $this->stdout("Nothing new in \"$category\" category... Nothing to save.\n\n", Console::FG_GREEN);
  345. return;
  346. }
  347. $merged = [];
  348. $untranslated = [];
  349. foreach ($messages as $message) {
  350. if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
  351. $merged[$message] = $existingMessages[$message];
  352. } else {
  353. $untranslated[] = $message;
  354. }
  355. }
  356. ksort($merged);
  357. sort($untranslated);
  358. $todo = [];
  359. foreach ($untranslated as $message) {
  360. $todo[$message] = '';
  361. }
  362. ksort($existingMessages);
  363. foreach ($existingMessages as $message => $translation) {
  364. if (!$removeUnused && !isset($merged[$message]) && !isset($todo[$message])) {
  365. if (!empty($translation) && strncmp($translation, '@@', 2) === 0 && substr_compare($translation, '@@', -2, 2) === 0) {
  366. $todo[$message] = $translation;
  367. } else {
  368. $todo[$message] = '@@' . $translation . '@@';
  369. }
  370. }
  371. }
  372. $merged = array_merge($todo, $merged);
  373. if ($sort) {
  374. ksort($merged);
  375. }
  376. if (false === $overwrite) {
  377. $fileName .= '.merged';
  378. }
  379. $this->stdout("Translation merged.\n");
  380. } else {
  381. $merged = [];
  382. foreach ($messages as $message) {
  383. $merged[$message] = '';
  384. }
  385. ksort($merged);
  386. }
  387. $array = VarDumper::export($merged);
  388. $content = <<<EOD
  389. <?php
  390. /**
  391. * Message translations.
  392. *
  393. * This file is automatically generated by 'yii {$this->id}' command.
  394. * It contains the localizable messages extracted from source code.
  395. * You may modify this file by translating the extracted messages.
  396. *
  397. * Each array element represents the translation (value) of a message (key).
  398. * If the value is empty, the message is considered as not translated.
  399. * Messages that no longer need translation will have their translations
  400. * enclosed between a pair of '@@' marks.
  401. *
  402. * Message string can be used with plural forms format. Check i18n section
  403. * of the guide for details.
  404. *
  405. * NOTE: this file must be saved in UTF-8 encoding.
  406. */
  407. return $array;
  408. EOD;
  409. file_put_contents($fileName, $content);
  410. $this->stdout("Translation saved.\n\n", Console::FG_GREEN);
  411. }
  412. /**
  413. * Writes messages into PO file
  414. *
  415. * @param array $messages
  416. * @param string $dirName name of the directory to write to
  417. * @param boolean $overwrite if existing file should be overwritten without backup
  418. * @param boolean $removeUnused if obsolete translations should be removed
  419. * @param boolean $sort if translations should be sorted
  420. * @param string $catalog message catalog
  421. */
  422. protected function saveMessagesToPO($messages, $dirName, $overwrite, $removeUnused, $sort, $catalog)
  423. {
  424. $file = str_replace("\\", '/', "$dirName/$catalog.po");
  425. FileHelper::createDirectory(dirname($file));
  426. $this->stdout("Saving messages to $file...\n");
  427. $poFile = new GettextPoFile();
  428. $merged = [];
  429. $todos = [];
  430. $hasSomethingToWrite = false;
  431. foreach ($messages as $category => $msgs) {
  432. $notTranslatedYet = [];
  433. $msgs = array_values(array_unique($msgs));
  434. if (is_file($file)) {
  435. $existingMessages = $poFile->load($file, $category);
  436. sort($msgs);
  437. ksort($existingMessages);
  438. if (array_keys($existingMessages) == $msgs) {
  439. $this->stdout("Nothing new in \"$category\" category...\n");
  440. sort($msgs);
  441. foreach ($msgs as $message) {
  442. $merged[$category . chr(4) . $message] = $existingMessages[$message];
  443. }
  444. ksort($merged);
  445. continue;
  446. }
  447. // merge existing message translations with new message translations
  448. foreach ($msgs as $message) {
  449. if (array_key_exists($message, $existingMessages) && $existingMessages[$message] !== '') {
  450. $merged[$category . chr(4) . $message] = $existingMessages[$message];
  451. } else {
  452. $notTranslatedYet[] = $message;
  453. }
  454. }
  455. ksort($merged);
  456. sort($notTranslatedYet);
  457. // collect not yet translated messages
  458. foreach ($notTranslatedYet as $message) {
  459. $todos[$category . chr(4) . $message] = '';
  460. }
  461. // add obsolete unused messages
  462. foreach ($existingMessages as $message => $translation) {
  463. if (!$removeUnused && !isset($merged[$category . chr(4) . $message]) && !isset($todos[$category . chr(4) . $message])) {
  464. if (!empty($translation) && substr($translation, 0, 2) === '@@' && substr($translation, -2) === '@@') {
  465. $todos[$category . chr(4) . $message] = $translation;
  466. } else {
  467. $todos[$category . chr(4) . $message] = '@@' . $translation . '@@';
  468. }
  469. }
  470. }
  471. $merged = array_merge($todos, $merged);
  472. if ($sort) {
  473. ksort($merged);
  474. }
  475. if ($overwrite === false) {
  476. $file .= '.merged';
  477. }
  478. } else {
  479. sort($msgs);
  480. foreach ($msgs as $message) {
  481. $merged[$category . chr(4) . $message] = '';
  482. }
  483. ksort($merged);
  484. }
  485. $this->stdout("Category \"$category\" merged.\n");
  486. $hasSomethingToWrite = true;
  487. }
  488. if ($hasSomethingToWrite) {
  489. $poFile->save($file, $merged);
  490. $this->stdout("Translation saved.\n", Console::FG_GREEN);
  491. } else {
  492. $this->stdout("Nothing to save.\n", Console::FG_GREEN);
  493. }
  494. }
  495. }