ExtractTask.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. <?php
  2. /**
  3. * Language string extractor
  4. *
  5. * PHP 5
  6. *
  7. * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
  8. * Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
  9. *
  10. * Licensed under The MIT License
  11. * Redistributions of files must retain the above copyright notice.
  12. *
  13. * @copyright Copyright 2005-2011, Cake Software Foundation, Inc. (http://cakefoundation.org)
  14. * @link http://cakephp.org CakePHP(tm) Project
  15. * @since CakePHP(tm) v 1.2.0.5012
  16. * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
  17. */
  18. App::uses('File', 'Utility');
  19. App::uses('Folder', 'Utility');
  20. /**
  21. * Language string extractor
  22. *
  23. * @package Cake.Console.Command.Task
  24. */
  25. class ExtractTask extends Shell {
  26. /**
  27. * Paths to use when looking for strings
  28. *
  29. * @var string
  30. */
  31. protected $_paths = array();
  32. /**
  33. * Files from where to extract
  34. *
  35. * @var array
  36. */
  37. protected $_files = array();
  38. /**
  39. * Merge all domains string into the default.pot file
  40. *
  41. * @var boolean
  42. */
  43. protected $_merge = false;
  44. /**
  45. * Current file being processed
  46. *
  47. * @var string
  48. */
  49. protected $_file = null;
  50. /**
  51. * Contains all content waiting to be write
  52. *
  53. * @var string
  54. */
  55. protected $_storage = array();
  56. /**
  57. * Extracted tokens
  58. *
  59. * @var array
  60. */
  61. protected $_tokens = array();
  62. /**
  63. * Extracted strings
  64. *
  65. * @var array
  66. */
  67. protected $_strings = array();
  68. /**
  69. * Destination path
  70. *
  71. * @var string
  72. */
  73. protected $_output = null;
  74. /**
  75. * An array of directories to exclude.
  76. *
  77. * @var array
  78. */
  79. protected $_exclude = array();
  80. /**
  81. * Holds whether this call should extract model validation messages
  82. *
  83. * @var boolean
  84. */
  85. protected $_extractValidation = true;
  86. /**
  87. * Holds the validation string domain to use for validation messages when extracting
  88. *
  89. * @var boolean
  90. */
  91. protected $_validationDomain = 'default';
  92. /**
  93. * Execution method always used for tasks
  94. *
  95. * @return void
  96. */
  97. public function execute() {
  98. if (!empty($this->params['exclude'])) {
  99. $this->_exclude = explode(',', $this->params['exclude']);
  100. }
  101. if (isset($this->params['files']) && !is_array($this->params['files'])) {
  102. $this->_files = explode(',', $this->params['files']);
  103. }
  104. if (isset($this->params['paths'])) {
  105. $this->_paths = explode(',', $this->params['paths']);
  106. } else if (isset($this->params['plugin'])) {
  107. $plugin = Inflector::camelize($this->params['plugin']);
  108. if (!CakePlugin::loaded($plugin)) {
  109. CakePlugin::load($plugin);
  110. }
  111. $this->_paths = array(CakePlugin::path($plugin));
  112. $this->params['plugin'] = $plugin;
  113. } else {
  114. $defaultPath = APP;
  115. $message = __d('cake_console', "What is the path you would like to extract?\n[Q]uit [D]one");
  116. while (true) {
  117. $response = $this->in($message, null, $defaultPath);
  118. if (strtoupper($response) === 'Q') {
  119. $this->out(__d('cake_console', 'Extract Aborted'));
  120. $this->_stop();
  121. } elseif (strtoupper($response) === 'D') {
  122. $this->out();
  123. break;
  124. } elseif (is_dir($response)) {
  125. $this->_paths[] = $response;
  126. $defaultPath = 'D';
  127. } else {
  128. $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
  129. }
  130. $this->out();
  131. }
  132. }
  133. if (!empty($this->params['exclude-plugins']) && $this->_isExtractingApp()) {
  134. $this->_exclude = array_merge($this->_exclude, App::path('plugins'));
  135. }
  136. if (!empty($this->params['ignore-model-validation']) || (!$this->_isExtractingApp() && empty($plugin))) {
  137. $this->_extractValidation = false;
  138. }
  139. if (!empty($this->params['validation-domain'])) {
  140. $this->_validationDomain = $this->params['validation-domain'];
  141. }
  142. if (isset($this->params['output'])) {
  143. $this->_output = $this->params['output'];
  144. } else if (isset($this->params['plugin'])) {
  145. $this->_output = $this->_paths[0] . DS . 'Locale';
  146. } else {
  147. $message = __d('cake_console', "What is the path you would like to output?\n[Q]uit", $this->_paths[0] . DS . 'Locale');
  148. while (true) {
  149. $response = $this->in($message, null, rtrim($this->_paths[0], DS) . DS . 'Locale');
  150. if (strtoupper($response) === 'Q') {
  151. $this->out(__d('cake_console', 'Extract Aborted'));
  152. $this->_stop();
  153. } elseif (is_dir($response)) {
  154. $this->_output = $response . DS;
  155. break;
  156. } else {
  157. $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
  158. }
  159. $this->out();
  160. }
  161. }
  162. if (isset($this->params['merge'])) {
  163. $this->_merge = !(strtolower($this->params['merge']) === 'no');
  164. } else {
  165. $this->out();
  166. $response = $this->in(__d('cake_console', 'Would you like to merge all domains strings into the default.pot file?'), array('y', 'n'), 'n');
  167. $this->_merge = strtolower($response) === 'y';
  168. }
  169. if (empty($this->_files)) {
  170. $this->_searchFiles();
  171. }
  172. $this->_output = rtrim($this->_output, DS) . DS;
  173. $this->_extract();
  174. }
  175. /**
  176. * Extract text
  177. *
  178. * @return void
  179. */
  180. protected function _extract() {
  181. $this->out();
  182. $this->out();
  183. $this->out(__d('cake_console', 'Extracting...'));
  184. $this->hr();
  185. $this->out(__d('cake_console', 'Paths:'));
  186. foreach ($this->_paths as $path) {
  187. $this->out(' ' . $path);
  188. }
  189. $this->out(__d('cake_console', 'Output Directory: ') . $this->_output);
  190. $this->hr();
  191. $this->_extractTokens();
  192. $this->_extractValidationMessages();
  193. $this->_buildFiles();
  194. $this->_writeFiles();
  195. $this->_paths = $this->_files = $this->_storage = array();
  196. $this->_strings = $this->_tokens = array();
  197. $this->_extractValidation = true;
  198. $this->out();
  199. $this->out(__d('cake_console', 'Done.'));
  200. }
  201. /**
  202. * Get & configure the option parser
  203. *
  204. * @return void
  205. */
  206. public function getOptionParser() {
  207. $parser = parent::getOptionParser();
  208. return $parser->description(__d('cake_console', 'CakePHP Language String Extraction:'))
  209. ->addOption('app', array('help' => __d('cake_console', 'Directory where your application is located.')))
  210. ->addOption('paths', array('help' => __d('cake_console', 'Comma separated list of paths.')))
  211. ->addOption('merge', array(
  212. 'help' => __d('cake_console', 'Merge all domain strings into the default.po file.'),
  213. 'choices' => array('yes', 'no')
  214. ))
  215. ->addOption('output', array('help' => __d('cake_console', 'Full path to output directory.')))
  216. ->addOption('files', array('help' => __d('cake_console', 'Comma separated list of files.')))
  217. ->addOption('exclude-plugins', array(
  218. 'boolean' => true,
  219. 'default' => true,
  220. 'help' => __d('cake_console', 'Ignores all files in plugins if this command is run inside from the same app directory.')
  221. ))
  222. ->addOption('plugin', array(
  223. 'help' => __d('cake_console', 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.')
  224. ))
  225. ->addOption('ignore-model-validation', array(
  226. 'boolean' => true,
  227. 'default' => false,
  228. 'help' => __d('cake_console', 'Ignores validation messages in the $validate property. If this flag is not set and the command is run from the same app directory, all messages in model validation rules will be extracted as tokens.')
  229. ))
  230. ->addOption('validation-domain', array(
  231. 'help' => __d('cake_console', 'If set to a value, the localization domain to be used for model validation messages.')
  232. ))
  233. ->addOption('exclude', array(
  234. 'help' => __d('cake_console', 'Comma separated list of directories to exclude. Any path containing a path segment with the provided values will be skipped. E.g. test,vendors')
  235. ));
  236. }
  237. /**
  238. * Extract tokens out of all files to be processed
  239. *
  240. * @return void
  241. */
  242. protected function _extractTokens() {
  243. foreach ($this->_files as $file) {
  244. $this->_file = $file;
  245. $this->out(__d('cake_console', 'Processing %s...', $file));
  246. $code = file_get_contents($file);
  247. $allTokens = token_get_all($code);
  248. $this->_tokens = array();
  249. foreach ($allTokens as $token) {
  250. if (!is_array($token) || ($token[0] != T_WHITESPACE && $token[0] != T_INLINE_HTML)) {
  251. $this->_tokens[] = $token;
  252. }
  253. }
  254. unset($allTokens);
  255. $this->_parse('__', array('singular'));
  256. $this->_parse('__n', array('singular', 'plural'));
  257. $this->_parse('__d', array('domain', 'singular'));
  258. $this->_parse('__c', array('singular'));
  259. $this->_parse('__dc', array('domain', 'singular'));
  260. $this->_parse('__dn', array('domain', 'singular', 'plural'));
  261. $this->_parse('__dcn', array('domain', 'singular', 'plural'));
  262. }
  263. }
  264. /**
  265. * Parse tokens
  266. *
  267. * @param string $functionName Function name that indicates translatable string (e.g: '__')
  268. * @param array $map Array containing what variables it will find (e.g: domain, singular, plural)
  269. * @return void
  270. */
  271. protected function _parse($functionName, $map) {
  272. $count = 0;
  273. $tokenCount = count($this->_tokens);
  274. while (($tokenCount - $count) > 1) {
  275. list($countToken, $firstParenthesis) = array($this->_tokens[$count], $this->_tokens[$count + 1]);
  276. if (!is_array($countToken)) {
  277. $count++;
  278. continue;
  279. }
  280. list($type, $string, $line) = $countToken;
  281. if (($type == T_STRING) && ($string == $functionName) && ($firstParenthesis == '(')) {
  282. $position = $count;
  283. $depth = 0;
  284. while ($depth == 0) {
  285. if ($this->_tokens[$position] == '(') {
  286. $depth++;
  287. } elseif ($this->_tokens[$position] == ')') {
  288. $depth--;
  289. }
  290. $position++;
  291. }
  292. $mapCount = count($map);
  293. $strings = $this->_getStrings($position, $mapCount);
  294. if ($mapCount == count($strings)) {
  295. extract(array_combine($map, $strings));
  296. $domain = isset($domain) ? $domain : 'default';
  297. $string = isset($plural) ? $singular . "\0" . $plural : $singular;
  298. $this->_strings[$domain][$string][$this->_file][] = $line;
  299. } else {
  300. $this->_markerError($this->_file, $line, $functionName, $count);
  301. }
  302. }
  303. $count++;
  304. }
  305. }
  306. /**
  307. * Looks for models in the application and extracts the validation messages
  308. * to be added to the translation map
  309. *
  310. * @return void
  311. */
  312. protected function _extractValidationMessages() {
  313. if (!$this->_extractValidation) {
  314. return;
  315. }
  316. App::uses('AppModel', 'Model');
  317. $plugin = null;
  318. if (!empty($this->params['plugin'])) {
  319. App::uses($this->params['plugin'] . 'AppModel', $this->params['plugin'] . '.Model');
  320. $plugin = $this->params['plugin'] . '.';
  321. }
  322. $models = App::objects($plugin . 'Model', null, false);
  323. foreach ($models as $model) {
  324. App::uses($model, $plugin . 'Model');
  325. $reflection = new ReflectionClass($model);
  326. if (!$reflection->isSubClassOf('Model')) {
  327. continue;
  328. }
  329. $properties = $reflection->getDefaultProperties();
  330. $validate = $properties['validate'];
  331. if (empty($validate)) {
  332. continue;
  333. }
  334. $file = $reflection->getFileName();
  335. $domain = $this->_validationDomain;
  336. if (!empty($properties['validationDomain'])) {
  337. $domain = $properties['validationDomain'];
  338. }
  339. foreach ($validate as $field => $rules) {
  340. $this->_processValidationRules($field, $rules, $file, $domain);
  341. }
  342. }
  343. }
  344. /**
  345. * Process a validation rule for a field and looks for a message to be added
  346. * to the translation map
  347. *
  348. * @param string $field the name of the field that is being processed
  349. * @param array $rules the set of validation rules for the field
  350. * @param string $file the file name where this validation rule was found
  351. * @param string $domain default domain to bind the validations to
  352. * @return void
  353. */
  354. protected function _processValidationRules($field, $rules, $file, $domain) {
  355. if (is_array($rules)) {
  356. $dims = Set::countDim($rules);
  357. if ($dims == 1 || ($dims == 2 && isset($rules['message']))) {
  358. $rules = array($rules);
  359. }
  360. foreach ($rules as $rule => $validateProp) {
  361. $message = null;
  362. if (isset($validateProp['message'])) {
  363. if (is_array($validateProp['message'])) {
  364. $message = $validateProp['message'][0];
  365. } else {
  366. $message = $validateProp['message'];
  367. }
  368. } elseif (is_string($rule)) {
  369. $message = $rule;
  370. }
  371. if ($message) {
  372. $this->_strings[$domain][$message][$file][] = 'validation for field ' . $field;
  373. }
  374. }
  375. }
  376. }
  377. /**
  378. * Build the translate template file contents out of obtained strings
  379. *
  380. * @return void
  381. */
  382. protected function _buildFiles() {
  383. foreach ($this->_strings as $domain => $strings) {
  384. foreach ($strings as $string => $files) {
  385. $occurrences = array();
  386. foreach ($files as $file => $lines) {
  387. $occurrences[] = $file . ':' . implode(';', $lines);
  388. }
  389. $occurrences = implode("\n#: ", $occurrences);
  390. $header = '#: ' . str_replace($this->_paths, '', $occurrences) . "\n";
  391. if (strpos($string, "\0") === false) {
  392. $sentence = "msgid \"{$string}\"\n";
  393. $sentence .= "msgstr \"\"\n\n";
  394. } else {
  395. list($singular, $plural) = explode("\0", $string);
  396. $sentence = "msgid \"{$singular}\"\n";
  397. $sentence .= "msgid_plural \"{$plural}\"\n";
  398. $sentence .= "msgstr[0] \"\"\n";
  399. $sentence .= "msgstr[1] \"\"\n\n";
  400. }
  401. $this->_store($domain, $header, $sentence);
  402. if ($domain != 'default' && $this->_merge) {
  403. $this->_store('default', $header, $sentence);
  404. }
  405. }
  406. }
  407. }
  408. /**
  409. * Prepare a file to be stored
  410. *
  411. * @param string $domain
  412. * @param string $header
  413. * @param string $sentence
  414. * @return void
  415. */
  416. protected function _store($domain, $header, $sentence) {
  417. if (!isset($this->_storage[$domain])) {
  418. $this->_storage[$domain] = array();
  419. }
  420. if (!isset($this->_storage[$domain][$sentence])) {
  421. $this->_storage[$domain][$sentence] = $header;
  422. } else {
  423. $this->_storage[$domain][$sentence] .= $header;
  424. }
  425. }
  426. /**
  427. * Write the files that need to be stored
  428. *
  429. * @return void
  430. */
  431. protected function _writeFiles() {
  432. $overwriteAll = false;
  433. foreach ($this->_storage as $domain => $sentences) {
  434. $output = $this->_writeHeader();
  435. foreach ($sentences as $sentence => $header) {
  436. $output .= $header . $sentence;
  437. }
  438. $filename = $domain . '.pot';
  439. $File = new File($this->_output . $filename);
  440. $response = '';
  441. while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') {
  442. $this->out();
  443. $response = $this->in(__d('cake_console', 'Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename), array('y', 'n', 'a'), 'y');
  444. if (strtoupper($response) === 'N') {
  445. $response = '';
  446. while ($response == '') {
  447. $response = $this->in(__d('cake_console', "What would you like to name this file?"), null, 'new_' . $filename);
  448. $File = new File($this->_output . $response);
  449. $filename = $response;
  450. }
  451. } elseif (strtoupper($response) === 'A') {
  452. $overwriteAll = true;
  453. }
  454. }
  455. $File->write($output);
  456. $File->close();
  457. }
  458. }
  459. /**
  460. * Build the translation template header
  461. *
  462. * @return string Translation template header
  463. */
  464. protected function _writeHeader() {
  465. $output = "# LANGUAGE translation of CakePHP Application\n";
  466. $output .= "# Copyright YEAR NAME <EMAIL@ADDRESS>\n";
  467. $output .= "#\n";
  468. $output .= "#, fuzzy\n";
  469. $output .= "msgid \"\"\n";
  470. $output .= "msgstr \"\"\n";
  471. $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
  472. $output .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
  473. $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
  474. $output .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
  475. $output .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
  476. $output .= "\"MIME-Version: 1.0\\n\"\n";
  477. $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
  478. $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
  479. $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n";
  480. return $output;
  481. }
  482. /**
  483. * Get the strings from the position forward
  484. *
  485. * @param integer $position Actual position on tokens array
  486. * @param integer $target Number of strings to extract
  487. * @return array Strings extracted
  488. */
  489. protected function _getStrings(&$position, $target) {
  490. $strings = array();
  491. while (count($strings) < $target && ($this->_tokens[$position] == ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING)) {
  492. if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position+1] == '.') {
  493. $string = '';
  494. while ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position] == '.') {
  495. if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
  496. $string .= $this->_formatString($this->_tokens[$position][1]);
  497. }
  498. $position++;
  499. }
  500. $strings[] = $string;
  501. } else if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
  502. $strings[] = $this->_formatString($this->_tokens[$position][1]);
  503. }
  504. $position++;
  505. }
  506. return $strings;
  507. }
  508. /**
  509. * Format a string to be added as a translatable string
  510. *
  511. * @param string $string String to format
  512. * @return string Formatted string
  513. */
  514. protected function _formatString($string) {
  515. $quote = substr($string, 0, 1);
  516. $string = substr($string, 1, -1);
  517. if ($quote == '"') {
  518. $string = stripcslashes($string);
  519. } else {
  520. $string = strtr($string, array("\\'" => "'", "\\\\" => "\\"));
  521. }
  522. $string = str_replace("\r\n", "\n", $string);
  523. return addcslashes($string, "\0..\37\\\"");
  524. }
  525. /**
  526. * Indicate an invalid marker on a processed file
  527. *
  528. * @param string $file File where invalid marker resides
  529. * @param integer $line Line number
  530. * @param string $marker Marker found
  531. * @param integer $count Count
  532. * @return void
  533. */
  534. protected function _markerError($file, $line, $marker, $count) {
  535. $this->out(__d('cake_console', "Invalid marker content in %s:%s\n* %s(", $file, $line, $marker), true);
  536. $count += 2;
  537. $tokenCount = count($this->_tokens);
  538. $parenthesis = 1;
  539. while ((($tokenCount - $count) > 0) && $parenthesis) {
  540. if (is_array($this->_tokens[$count])) {
  541. $this->out($this->_tokens[$count][1], false);
  542. } else {
  543. $this->out($this->_tokens[$count], false);
  544. if ($this->_tokens[$count] == '(') {
  545. $parenthesis++;
  546. }
  547. if ($this->_tokens[$count] == ')') {
  548. $parenthesis--;
  549. }
  550. }
  551. $count++;
  552. }
  553. $this->out("\n", true);
  554. }
  555. /**
  556. * Search files that may contain translatable strings
  557. *
  558. * @return void
  559. */
  560. protected function _searchFiles() {
  561. $pattern = false;
  562. if (!empty($this->_exclude)) {
  563. $exclude = array();
  564. foreach ($this->_exclude as $e) {
  565. if (DS !== '\\' && $e[0] !== DS) {
  566. $e = DS . $e;
  567. }
  568. $exclude[] = preg_quote($e, '/');
  569. }
  570. $pattern = '/' . implode('|', $exclude) . '/';
  571. }
  572. foreach ($this->_paths as $path) {
  573. $Folder = new Folder($path);
  574. $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true);
  575. if (!empty($pattern)) {
  576. foreach ($files as $i => $file) {
  577. if (preg_match($pattern, $file)) {
  578. unset($files[$i]);
  579. }
  580. }
  581. $files = array_values($files);
  582. }
  583. $this->_files = array_merge($this->_files, $files);
  584. }
  585. }
  586. /**
  587. * Returns whether this execution is meant to extract string only from directories in folder represented by the
  588. * APP constant, i.e. this task is extracting strings from same application.
  589. *
  590. * @return boolean
  591. */
  592. protected function _isExtractingApp() {
  593. return $this->_paths === array(APP);
  594. }
  595. }