1: <?php
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16:
17:
18: App::uses('AppShell', 'Console/Command');
19: App::uses('File', 'Utility');
20: App::uses('Folder', 'Utility');
21: App::uses('Hash', 'Utility');
22:
23: 24: 25: 26: 27:
28: class ExtractTask extends AppShell {
29:
30: 31: 32: 33: 34:
35: protected $_paths = array();
36:
37: 38: 39: 40: 41:
42: protected $_files = array();
43:
44: 45: 46: 47: 48:
49: protected $_merge = false;
50:
51: 52: 53: 54: 55:
56: protected $_file = null;
57:
58: 59: 60: 61: 62:
63: protected $_storage = array();
64:
65: 66: 67: 68: 69:
70: protected $_tokens = array();
71:
72: 73: 74: 75: 76:
77: protected $_translations = array();
78:
79: 80: 81: 82: 83:
84: protected $_output = null;
85:
86: 87: 88: 89: 90:
91: protected $_exclude = array();
92:
93: 94: 95: 96: 97:
98: protected $_extractValidation = true;
99:
100: 101: 102: 103: 104:
105: protected $_validationDomain = 'default';
106:
107: 108: 109: 110: 111:
112: protected $_extractCore = false;
113:
114: 115: 116: 117: 118:
119: protected function _getPaths() {
120: $defaultPath = APP;
121: while (true) {
122: $currentPaths = count($this->_paths) > 0 ? $this->_paths : array('None');
123: $message = __d(
124: 'cake_console',
125: "Current paths: %s\nWhat is the path you would like to extract?\n[Q]uit [D]one",
126: implode(', ', $currentPaths)
127: );
128: $response = $this->in($message, null, $defaultPath);
129: if (strtoupper($response) === 'Q') {
130: $this->err(__d('cake_console', 'Extract Aborted'));
131: return $this->_stop();
132: } elseif (strtoupper($response) === 'D' && count($this->_paths)) {
133: $this->out();
134: return;
135: } elseif (strtoupper($response) === 'D') {
136: $this->err(__d('cake_console', '<warning>No directories selected.</warning> Please choose a directory.'));
137: } elseif (is_dir($response)) {
138: $this->_paths[] = $response;
139: $defaultPath = 'D';
140: } else {
141: $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
142: }
143: $this->out();
144: }
145: }
146:
147: 148: 149: 150: 151:
152: public function execute() {
153: if (!empty($this->params['exclude'])) {
154: $this->_exclude = explode(',', $this->params['exclude']);
155: }
156: if (isset($this->params['files']) && !is_array($this->params['files'])) {
157: $this->_files = explode(',', $this->params['files']);
158: }
159: if (isset($this->params['paths'])) {
160: $this->_paths = explode(',', $this->params['paths']);
161: } elseif (isset($this->params['plugin'])) {
162: $plugin = Inflector::camelize($this->params['plugin']);
163: if (!CakePlugin::loaded($plugin)) {
164: CakePlugin::load($plugin);
165: }
166: $this->_paths = array(CakePlugin::path($plugin));
167: $this->params['plugin'] = $plugin;
168: } else {
169: $this->_getPaths();
170: }
171:
172: if (isset($this->params['extract-core'])) {
173: $this->_extractCore = !(strtolower($this->params['extract-core']) === 'no');
174: } else {
175: $response = $this->in(__d('cake_console', 'Would you like to extract the messages from the CakePHP core?'), array('y', 'n'), 'n');
176: $this->_extractCore = strtolower($response) === 'y';
177: }
178:
179: if (!empty($this->params['exclude-plugins']) && $this->_isExtractingApp()) {
180: $this->_exclude = array_merge($this->_exclude, App::path('plugins'));
181: }
182:
183: if (!empty($this->params['ignore-model-validation']) || (!$this->_isExtractingApp() && empty($plugin))) {
184: $this->_extractValidation = false;
185: }
186: if (!empty($this->params['validation-domain'])) {
187: $this->_validationDomain = $this->params['validation-domain'];
188: }
189:
190: if ($this->_extractCore) {
191: $this->_paths[] = CAKE;
192: $this->_exclude = array_merge($this->_exclude, array(
193: CAKE . 'Test',
194: CAKE . 'Console' . DS . 'Templates'
195: ));
196: }
197:
198: if (isset($this->params['output'])) {
199: $this->_output = $this->params['output'];
200: } elseif (isset($this->params['plugin'])) {
201: $this->_output = $this->_paths[0] . DS . 'Locale';
202: } else {
203: $message = __d('cake_console', "What is the path you would like to output?\n[Q]uit", $this->_paths[0] . DS . 'Locale');
204: while (true) {
205: $response = $this->in($message, null, rtrim($this->_paths[0], DS) . DS . 'Locale');
206: if (strtoupper($response) === 'Q') {
207: $this->err(__d('cake_console', 'Extract Aborted'));
208: return $this->_stop();
209: } elseif ($this->_isPathUsable($response)) {
210: $this->_output = $response . DS;
211: break;
212: } else {
213: $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.'));
214: }
215: $this->out();
216: }
217: }
218:
219: if (isset($this->params['merge'])) {
220: $this->_merge = !(strtolower($this->params['merge']) === 'no');
221: } else {
222: $this->out();
223: $response = $this->in(__d('cake_console', 'Would you like to merge all domain and category strings into the default.pot file?'), array('y', 'n'), 'n');
224: $this->_merge = strtolower($response) === 'y';
225: }
226:
227: if (empty($this->_files)) {
228: $this->_searchFiles();
229: }
230:
231: $this->_output = rtrim($this->_output, DS) . DS;
232: if (!$this->_isPathUsable($this->_output)) {
233: $this->err(__d('cake_console', 'The output directory %s was not found or writable.', $this->_output));
234: return $this->_stop();
235: }
236:
237: $this->_extract();
238: }
239:
240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250:
251: protected function _addTranslation($category, $domain, $msgid, $details = array()) {
252: if (empty($this->_translations[$category][$domain][$msgid])) {
253: $this->_translations[$category][$domain][$msgid] = array(
254: 'msgid_plural' => false
255: );
256: }
257:
258: if (isset($details['msgid_plural'])) {
259: $this->_translations[$category][$domain][$msgid]['msgid_plural'] = $details['msgid_plural'];
260: }
261:
262: if (isset($details['file'])) {
263: $line = 0;
264: if (isset($details['line'])) {
265: $line = $details['line'];
266: }
267: $this->_translations[$category][$domain][$msgid]['references'][$details['file']][] = $line;
268: }
269: }
270:
271: 272: 273: 274: 275:
276: protected function _extract() {
277: $this->out();
278: $this->out();
279: $this->out(__d('cake_console', 'Extracting...'));
280: $this->hr();
281: $this->out(__d('cake_console', 'Paths:'));
282: foreach ($this->_paths as $path) {
283: $this->out(' ' . $path);
284: }
285: $this->out(__d('cake_console', 'Output Directory: ') . $this->_output);
286: $this->hr();
287: $this->_extractTokens();
288: $this->_extractValidationMessages();
289: $this->_buildFiles();
290: $this->_writeFiles();
291: $this->_paths = $this->_files = $this->_storage = array();
292: $this->_translations = $this->_tokens = array();
293: $this->_extractValidation = true;
294: $this->out();
295: $this->out(__d('cake_console', 'Done.'));
296: }
297:
298: 299: 300: 301: 302:
303: public function getOptionParser() {
304: $parser = parent::getOptionParser();
305:
306: $parser->description(
307: __d('cake_console', 'CakePHP Language String Extraction:')
308: )->addOption('app', array(
309: 'help' => __d('cake_console', 'Directory where your application is located.')
310: ))->addOption('paths', array(
311: 'help' => __d('cake_console', 'Comma separated list of paths.')
312: ))->addOption('merge', array(
313: 'help' => __d('cake_console', 'Merge all domain and category strings into the default.po file.'),
314: 'choices' => array('yes', 'no')
315: ))->addOption('output', array(
316: 'help' => __d('cake_console', 'Full path to output directory.')
317: ))->addOption('files', array(
318: 'help' => __d('cake_console', 'Comma separated list of files.')
319: ))->addOption('exclude-plugins', array(
320: 'boolean' => true,
321: 'default' => true,
322: 'help' => __d('cake_console', 'Ignores all files in plugins if this command is run inside from the same app directory.')
323: ))->addOption('plugin', array(
324: 'help' => __d('cake_console', 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.')
325: ))->addOption('ignore-model-validation', array(
326: 'boolean' => true,
327: 'default' => false,
328: 'help' => __d('cake_console', 'Ignores validation messages in the $validate property.' .
329: ' If this flag is not set and the command is run from the same app directory,' .
330: ' all messages in model validation rules will be extracted as tokens.'
331: )
332: ))->addOption('validation-domain', array(
333: 'help' => __d('cake_console', 'If set to a value, the localization domain to be used for model validation messages.')
334: ))->addOption('exclude', array(
335: 'help' => __d('cake_console', 'Comma separated list of directories to exclude.' .
336: ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors'
337: )
338: ))->addOption('overwrite', array(
339: 'boolean' => true,
340: 'default' => false,
341: 'help' => __d('cake_console', 'Always overwrite existing .pot files.')
342: ))->addOption('extract-core', array(
343: 'help' => __d('cake_console', 'Extract messages from the CakePHP core libs.'),
344: 'choices' => array('yes', 'no')
345: ));
346:
347: return $parser;
348: }
349:
350: 351: 352: 353: 354:
355: protected function _extractTokens() {
356: foreach ($this->_files as $file) {
357: $this->_file = $file;
358: $this->out(__d('cake_console', 'Processing %s...', $file));
359:
360: $code = file_get_contents($file);
361: $allTokens = token_get_all($code);
362:
363: $this->_tokens = array();
364: foreach ($allTokens as $token) {
365: if (!is_array($token) || ($token[0] != T_WHITESPACE && $token[0] != T_INLINE_HTML)) {
366: $this->_tokens[] = $token;
367: }
368: }
369: unset($allTokens);
370: $this->_parse('__', array('singular'));
371: $this->_parse('__n', array('singular', 'plural'));
372: $this->_parse('__d', array('domain', 'singular'));
373: $this->_parse('__c', array('singular', 'category'));
374: $this->_parse('__dc', array('domain', 'singular', 'category'));
375: $this->_parse('__dn', array('domain', 'singular', 'plural'));
376: $this->_parse('__dcn', array('domain', 'singular', 'plural', 'count', 'category'));
377: }
378: }
379:
380: 381: 382: 383: 384: 385: 386:
387: protected function _parse($functionName, $map) {
388: $count = 0;
389: $categories = array('LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY', 'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES');
390: $tokenCount = count($this->_tokens);
391:
392: while (($tokenCount - $count) > 1) {
393: $countToken = $this->_tokens[$count];
394: $firstParenthesis = $this->_tokens[$count + 1];
395: if (!is_array($countToken)) {
396: $count++;
397: continue;
398: }
399:
400: list($type, $string, $line) = $countToken;
401: if (($type == T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) {
402: $position = $count;
403: $depth = 0;
404:
405: while (!$depth) {
406: if ($this->_tokens[$position] === '(') {
407: $depth++;
408: } elseif ($this->_tokens[$position] === ')') {
409: $depth--;
410: }
411: $position++;
412: }
413:
414: $mapCount = count($map);
415: $strings = $this->_getStrings($position, $mapCount);
416:
417: if ($mapCount === count($strings)) {
418: extract(array_combine($map, $strings));
419: $category = isset($category) ? $category : 6;
420: $category = (int)$category;
421: $categoryName = $categories[$category];
422: $domain = isset($domain) ? $domain : 'default';
423: $details = array(
424: 'file' => $this->_file,
425: 'line' => $line,
426: );
427: if (isset($plural)) {
428: $details['msgid_plural'] = $plural;
429: }
430: $this->_addTranslation($categoryName, $domain, $singular, $details);
431: } else {
432: $this->_markerError($this->_file, $line, $functionName, $count);
433: }
434: }
435: $count++;
436: }
437: }
438:
439: 440: 441: 442: 443: 444:
445: protected function _extractValidationMessages() {
446: if (!$this->_extractValidation) {
447: return;
448: }
449:
450: $plugins = array(null);
451: if (empty($this->params['exclude-plugins'])) {
452: $plugins = array_merge($plugins, App::objects('plugin', null, false));
453: }
454: foreach ($plugins as $plugin) {
455: $this->_extractPluginValidationMessages($plugin);
456: }
457: }
458:
459: 460: 461: 462: 463: 464:
465: protected function _extractPluginValidationMessages($plugin = null) {
466: App::uses('AppModel', 'Model');
467: if (!empty($plugin)) {
468: if (!CakePlugin::loaded($plugin)) {
469: return;
470: }
471: App::uses($plugin . 'AppModel', $plugin . '.Model');
472: $plugin = $plugin . '.';
473: }
474: $models = App::objects($plugin . 'Model', null, false);
475:
476: foreach ($models as $model) {
477: App::uses($model, $plugin . 'Model');
478: $reflection = new ReflectionClass($model);
479: if (!$reflection->isSubClassOf('Model')) {
480: continue;
481: }
482: $properties = $reflection->getDefaultProperties();
483: $validate = $properties['validate'];
484: if (empty($validate)) {
485: continue;
486: }
487:
488: $file = $reflection->getFileName();
489: $domain = $this->_validationDomain;
490: if (!empty($properties['validationDomain'])) {
491: $domain = $properties['validationDomain'];
492: }
493: foreach ($validate as $field => $rules) {
494: $this->_processValidationRules($field, $rules, $file, $domain);
495: }
496: }
497: }
498:
499: 500: 501: 502: 503: 504: 505: 506: 507: 508: 509:
510: protected function _processValidationRules($field, $rules, $file, $domain, $category = 'LC_MESSAGES') {
511: if (!is_array($rules)) {
512: return;
513: }
514:
515: $dims = Hash::dimensions($rules);
516: if ($dims === 1 || ($dims === 2 && isset($rules['message']))) {
517: $rules = array($rules);
518: }
519:
520: foreach ($rules as $rule => $validateProp) {
521: $msgid = null;
522: if (isset($validateProp['message'])) {
523: if (is_array($validateProp['message'])) {
524: $msgid = $validateProp['message'][0];
525: } else {
526: $msgid = $validateProp['message'];
527: }
528: } elseif (is_string($rule)) {
529: $msgid = $rule;
530: }
531: if ($msgid) {
532: $msgid = $this->_formatString(sprintf("'%s'", $msgid));
533: $details = array(
534: 'file' => $file,
535: 'line' => 'validation for field ' . $field
536: );
537: $this->_addTranslation($category, $domain, $msgid, $details);
538: }
539: }
540: }
541:
542: 543: 544: 545: 546:
547: protected function _buildFiles() {
548: $paths = $this->_paths;
549: $paths[] = realpath(APP) . DS;
550: foreach ($this->_translations as $category => $domains) {
551: foreach ($domains as $domain => $translations) {
552: foreach ($translations as $msgid => $details) {
553: $plural = $details['msgid_plural'];
554: $files = $details['references'];
555: $occurrences = array();
556: foreach ($files as $file => $lines) {
557: $lines = array_unique($lines);
558: $occurrences[] = $file . ':' . implode(';', $lines);
559: }
560: $occurrences = implode("\n#: ", $occurrences);
561: $header = '#: ' . str_replace(DS, '/', str_replace($paths, '', $occurrences)) . "\n";
562:
563: if ($plural === false) {
564: $sentence = "msgid \"{$msgid}\"\n";
565: $sentence .= "msgstr \"\"\n\n";
566: } else {
567: $sentence = "msgid \"{$msgid}\"\n";
568: $sentence .= "msgid_plural \"{$plural}\"\n";
569: $sentence .= "msgstr[0] \"\"\n";
570: $sentence .= "msgstr[1] \"\"\n\n";
571: }
572:
573: $this->_store($category, $domain, $header, $sentence);
574: if (($category !== 'LC_MESSAGES' || $domain !== 'default') && $this->_merge) {
575: $this->_store('LC_MESSAGES', 'default', $header, $sentence);
576: }
577: }
578: }
579: }
580: }
581:
582: 583: 584: 585: 586: 587: 588: 589: 590:
591: protected function _store($category, $domain, $header, $sentence) {
592: if (!isset($this->_storage[$category])) {
593: $this->_storage[$category] = array();
594: }
595: if (!isset($this->_storage[$category][$domain])) {
596: $this->_storage[$category][$domain] = array();
597: }
598: if (!isset($this->_storage[$category][$domain][$sentence])) {
599: $this->_storage[$category][$domain][$sentence] = $header;
600: } else {
601: $this->_storage[$category][$domain][$sentence] .= $header;
602: }
603: }
604:
605: 606: 607: 608: 609:
610: protected function _writeFiles() {
611: $overwriteAll = false;
612: if (!empty($this->params['overwrite'])) {
613: $overwriteAll = true;
614: }
615: foreach ($this->_storage as $category => $domains) {
616: foreach ($domains as $domain => $sentences) {
617: $output = $this->_writeHeader();
618: foreach ($sentences as $sentence => $header) {
619: $output .= $header . $sentence;
620: }
621:
622: $filename = $domain . '.pot';
623: if ($category === 'LC_MESSAGES') {
624: $File = new File($this->_output . $filename);
625: } else {
626: new Folder($this->_output . $category, true);
627: $File = new File($this->_output . $category . DS . $filename);
628: }
629: $response = '';
630: while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') {
631: $this->out();
632: $response = $this->in(
633: __d('cake_console', 'Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename),
634: array('y', 'n', 'a'),
635: 'y'
636: );
637: if (strtoupper($response) === 'N') {
638: $response = '';
639: while (!$response) {
640: $response = $this->in(__d('cake_console', "What would you like to name this file?"), null, 'new_' . $filename);
641: $File = new File($this->_output . $response);
642: $filename = $response;
643: }
644: } elseif (strtoupper($response) === 'A') {
645: $overwriteAll = true;
646: }
647: }
648: $File->write($output);
649: $File->close();
650: }
651: }
652: }
653:
654: 655: 656: 657: 658:
659: protected function _writeHeader() {
660: $output = "# LANGUAGE translation of CakePHP Application\n";
661: $output .= "# Copyright YEAR NAME <EMAIL@ADDRESS>\n";
662: $output .= "#\n";
663: $output .= "#, fuzzy\n";
664: $output .= "msgid \"\"\n";
665: $output .= "msgstr \"\"\n";
666: $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
667: $output .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
668: $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
669: $output .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
670: $output .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
671: $output .= "\"MIME-Version: 1.0\\n\"\n";
672: $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
673: $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
674: $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n";
675: return $output;
676: }
677:
678: 679: 680: 681: 682: 683: 684:
685: protected function _getStrings(&$position, $target) {
686: $strings = array();
687: $count = count($strings);
688: while ($count < $target && ($this->_tokens[$position] === ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position][0] == T_LNUMBER)) {
689: $count = count($strings);
690: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') {
691: $string = '';
692: while ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position] === '.') {
693: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
694: $string .= $this->_formatString($this->_tokens[$position][1]);
695: }
696: $position++;
697: }
698: $strings[] = $string;
699: } elseif ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
700: $strings[] = $this->_formatString($this->_tokens[$position][1]);
701: } elseif ($this->_tokens[$position][0] == T_LNUMBER) {
702: $strings[] = $this->_tokens[$position][1];
703: }
704: $position++;
705: }
706: return $strings;
707: }
708:
709: 710: 711: 712: 713: 714:
715: protected function _formatString($string) {
716: $quote = substr($string, 0, 1);
717: $string = substr($string, 1, -1);
718: if ($quote === '"') {
719: $string = stripcslashes($string);
720: } else {
721: $string = strtr($string, array("\\'" => "'", "\\\\" => "\\"));
722: }
723: $string = str_replace("\r\n", "\n", $string);
724: return addcslashes($string, "\0..\37\\\"");
725: }
726:
727: 728: 729: 730: 731: 732: 733: 734: 735:
736: protected function _markerError($file, $line, $marker, $count) {
737: $this->err(__d('cake_console', "Invalid marker content in %s:%s\n* %s(", $file, $line, $marker));
738: $count += 2;
739: $tokenCount = count($this->_tokens);
740: $parenthesis = 1;
741:
742: while ((($tokenCount - $count) > 0) && $parenthesis) {
743: if (is_array($this->_tokens[$count])) {
744: $this->err($this->_tokens[$count][1], false);
745: } else {
746: $this->err($this->_tokens[$count], false);
747: if ($this->_tokens[$count] === '(') {
748: $parenthesis++;
749: }
750:
751: if ($this->_tokens[$count] === ')') {
752: $parenthesis--;
753: }
754: }
755: $count++;
756: }
757: $this->err("\n", true);
758: }
759:
760: 761: 762: 763: 764:
765: protected function _searchFiles() {
766: $pattern = false;
767: if (!empty($this->_exclude)) {
768: $exclude = array();
769: foreach ($this->_exclude as $e) {
770: if (DS !== '\\' && $e[0] !== DS) {
771: $e = DS . $e;
772: }
773: $exclude[] = preg_quote($e, '/');
774: }
775: $pattern = '/' . implode('|', $exclude) . '/';
776: }
777: foreach ($this->_paths as $path) {
778: $Folder = new Folder($path);
779: $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true);
780: if (!empty($pattern)) {
781: foreach ($files as $i => $file) {
782: if (preg_match($pattern, $file)) {
783: unset($files[$i]);
784: }
785: }
786: $files = array_values($files);
787: }
788: $this->_files = array_merge($this->_files, $files);
789: }
790: }
791:
792: 793: 794: 795: 796: 797:
798: protected function _isExtractingApp() {
799: return $this->_paths === array(APP);
800: }
801:
802: 803: 804: 805: 806: 807:
808: protected function _isPathUsable($path) {
809: return is_dir($path) && is_writable($path);
810: }
811: }
812: