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->out(__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->out(__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: return $parser->description(__d('cake_console', 'CakePHP Language String Extraction:'))
306: ->addOption('app', array('help' => __d('cake_console', 'Directory where your application is located.')))
307: ->addOption('paths', array('help' => __d('cake_console', 'Comma separated list of paths.')))
308: ->addOption('merge', array(
309: 'help' => __d('cake_console', 'Merge all domain and category strings into the default.po file.'),
310: 'choices' => array('yes', 'no')
311: ))
312: ->addOption('output', array('help' => __d('cake_console', 'Full path to output directory.')))
313: ->addOption('files', array('help' => __d('cake_console', 'Comma separated list of files.')))
314: ->addOption('exclude-plugins', array(
315: 'boolean' => true,
316: 'default' => true,
317: 'help' => __d('cake_console', 'Ignores all files in plugins if this command is run inside from the same app directory.')
318: ))
319: ->addOption('plugin', array(
320: 'help' => __d('cake_console', 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.')
321: ))
322: ->addOption('ignore-model-validation', array(
323: 'boolean' => true,
324: 'default' => false,
325: 'help' => __d('cake_console', 'Ignores validation messages in the $validate property.' .
326: ' If this flag is not set and the command is run from the same app directory,' .
327: ' all messages in model validation rules will be extracted as tokens.')
328: ))
329: ->addOption('validation-domain', array(
330: 'help' => __d('cake_console', 'If set to a value, the localization domain to be used for model validation messages.')
331: ))
332: ->addOption('exclude', array(
333: 'help' => __d('cake_console', 'Comma separated list of directories to exclude.' .
334: ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors')
335: ))
336: ->addOption('overwrite', array(
337: 'boolean' => true,
338: 'default' => false,
339: 'help' => __d('cake_console', 'Always overwrite existing .pot files.')
340: ))
341: ->addOption('extract-core', array(
342: 'help' => __d('cake_console', 'Extract messages from the CakePHP core libs.'),
343: 'choices' => array('yes', 'no')
344: ));
345: }
346:
347: 348: 349: 350: 351:
352: protected function _extractTokens() {
353: foreach ($this->_files as $file) {
354: $this->_file = $file;
355: $this->out(__d('cake_console', 'Processing %s...', $file));
356:
357: $code = file_get_contents($file);
358: $allTokens = token_get_all($code);
359:
360: $this->_tokens = array();
361: foreach ($allTokens as $token) {
362: if (!is_array($token) || ($token[0] != T_WHITESPACE && $token[0] != T_INLINE_HTML)) {
363: $this->_tokens[] = $token;
364: }
365: }
366: unset($allTokens);
367: $this->_parse('__', array('singular'));
368: $this->_parse('__n', array('singular', 'plural'));
369: $this->_parse('__d', array('domain', 'singular'));
370: $this->_parse('__c', array('singular', 'category'));
371: $this->_parse('__dc', array('domain', 'singular', 'category'));
372: $this->_parse('__dn', array('domain', 'singular', 'plural'));
373: $this->_parse('__dcn', array('domain', 'singular', 'plural', 'count', 'category'));
374: }
375: }
376:
377: 378: 379: 380: 381: 382: 383:
384: protected function _parse($functionName, $map) {
385: $count = 0;
386: $categories = array('LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY', 'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES');
387: $tokenCount = count($this->_tokens);
388:
389: while (($tokenCount - $count) > 1) {
390: $countToken = $this->_tokens[$count];
391: $firstParenthesis = $this->_tokens[$count + 1];
392: if (!is_array($countToken)) {
393: $count++;
394: continue;
395: }
396:
397: list($type, $string, $line) = $countToken;
398: if (($type == T_STRING) && ($string == $functionName) && ($firstParenthesis === '(')) {
399: $position = $count;
400: $depth = 0;
401:
402: while (!$depth) {
403: if ($this->_tokens[$position] === '(') {
404: $depth++;
405: } elseif ($this->_tokens[$position] === ')') {
406: $depth--;
407: }
408: $position++;
409: }
410:
411: $mapCount = count($map);
412: $strings = $this->_getStrings($position, $mapCount);
413:
414: if ($mapCount === count($strings)) {
415: extract(array_combine($map, $strings));
416: $category = isset($category) ? $category : 6;
417: $category = intval($category);
418: $categoryName = $categories[$category];
419: $domain = isset($domain) ? $domain : 'default';
420: $details = array(
421: 'file' => $this->_file,
422: 'line' => $line,
423: );
424: if (isset($plural)) {
425: $details['msgid_plural'] = $plural;
426: }
427: $this->_addTranslation($categoryName, $domain, $singular, $details);
428: } else {
429: $this->_markerError($this->_file, $line, $functionName, $count);
430: }
431: }
432: $count++;
433: }
434: }
435:
436: 437: 438: 439: 440: 441:
442: protected function _extractValidationMessages() {
443: if (!$this->_extractValidation) {
444: return;
445: }
446:
447: $plugins = array(null);
448: if (empty($this->params['exclude-plugins'])) {
449: $plugins = array_merge($plugins, App::objects('plugin', null, false));
450: }
451: foreach ($plugins as $plugin) {
452: $this->_extractPluginValidationMessages($plugin);
453: }
454: }
455:
456: 457: 458: 459: 460: 461:
462: protected function _extractPluginValidationMessages($plugin = null) {
463: App::uses('AppModel', 'Model');
464: if (!empty($plugin)) {
465: if (!CakePlugin::loaded($plugin)) {
466: return;
467: }
468: App::uses($plugin . 'AppModel', $plugin . '.Model');
469: $plugin = $plugin . '.';
470: }
471: $models = App::objects($plugin . 'Model', null, false);
472:
473: foreach ($models as $model) {
474: App::uses($model, $plugin . 'Model');
475: $reflection = new ReflectionClass($model);
476: if (!$reflection->isSubClassOf('Model')) {
477: continue;
478: }
479: $properties = $reflection->getDefaultProperties();
480: $validate = $properties['validate'];
481: if (empty($validate)) {
482: continue;
483: }
484:
485: $file = $reflection->getFileName();
486: $domain = $this->_validationDomain;
487: if (!empty($properties['validationDomain'])) {
488: $domain = $properties['validationDomain'];
489: }
490: foreach ($validate as $field => $rules) {
491: $this->_processValidationRules($field, $rules, $file, $domain);
492: }
493: }
494: }
495:
496: 497: 498: 499: 500: 501: 502: 503: 504: 505: 506:
507: protected function _processValidationRules($field, $rules, $file, $domain, $category = 'LC_MESSAGES') {
508: if (!is_array($rules)) {
509: return;
510: }
511:
512: $dims = Hash::dimensions($rules);
513: if ($dims === 1 || ($dims === 2 && isset($rules['message']))) {
514: $rules = array($rules);
515: }
516:
517: foreach ($rules as $rule => $validateProp) {
518: $msgid = null;
519: if (isset($validateProp['message'])) {
520: if (is_array($validateProp['message'])) {
521: $msgid = $validateProp['message'][0];
522: } else {
523: $msgid = $validateProp['message'];
524: }
525: } elseif (is_string($rule)) {
526: $msgid = $rule;
527: }
528: if ($msgid) {
529: $msgid = $this->_formatString(sprintf("'%s'", $msgid));
530: $details = array(
531: 'file' => $file,
532: 'line' => 'validation for field ' . $field
533: );
534: $this->_addTranslation($category, $domain, $msgid, $details);
535: }
536: }
537: }
538:
539: 540: 541: 542: 543:
544: protected function _buildFiles() {
545: $paths = $this->_paths;
546: $paths[] = realpath(APP) . DS;
547: foreach ($this->_translations as $category => $domains) {
548: foreach ($domains as $domain => $translations) {
549: foreach ($translations as $msgid => $details) {
550: $plural = $details['msgid_plural'];
551: $files = $details['references'];
552: $occurrences = array();
553: foreach ($files as $file => $lines) {
554: $lines = array_unique($lines);
555: $occurrences[] = $file . ':' . implode(';', $lines);
556: }
557: $occurrences = implode("\n#: ", $occurrences);
558: $header = '#: ' . str_replace(DS, '/', str_replace($paths, '', $occurrences)) . "\n";
559:
560: if ($plural === false) {
561: $sentence = "msgid \"{$msgid}\"\n";
562: $sentence .= "msgstr \"\"\n\n";
563: } else {
564: $sentence = "msgid \"{$msgid}\"\n";
565: $sentence .= "msgid_plural \"{$plural}\"\n";
566: $sentence .= "msgstr[0] \"\"\n";
567: $sentence .= "msgstr[1] \"\"\n\n";
568: }
569:
570: $this->_store($category, $domain, $header, $sentence);
571: if (($category !== 'LC_MESSAGES' || $domain !== 'default') && $this->_merge) {
572: $this->_store('LC_MESSAGES', 'default', $header, $sentence);
573: }
574: }
575: }
576: }
577: }
578:
579: 580: 581: 582: 583: 584: 585: 586: 587:
588: protected function _store($category, $domain, $header, $sentence) {
589: if (!isset($this->_storage[$category])) {
590: $this->_storage[$category] = array();
591: }
592: if (!isset($this->_storage[$category][$domain])) {
593: $this->_storage[$category][$domain] = array();
594: }
595: if (!isset($this->_storage[$category][$domain][$sentence])) {
596: $this->_storage[$category][$domain][$sentence] = $header;
597: } else {
598: $this->_storage[$category][$domain][$sentence] .= $header;
599: }
600: }
601:
602: 603: 604: 605: 606:
607: protected function _writeFiles() {
608: $overwriteAll = false;
609: if (!empty($this->params['overwrite'])) {
610: $overwriteAll = true;
611: }
612: foreach ($this->_storage as $category => $domains) {
613: foreach ($domains as $domain => $sentences) {
614: $output = $this->_writeHeader();
615: foreach ($sentences as $sentence => $header) {
616: $output .= $header . $sentence;
617: }
618:
619: $filename = $domain . '.pot';
620: if ($category === 'LC_MESSAGES') {
621: $File = new File($this->_output . $filename);
622: } else {
623: new Folder($this->_output . $category, true);
624: $File = new File($this->_output . $category . DS . $filename);
625: }
626: $response = '';
627: while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') {
628: $this->out();
629: $response = $this->in(
630: __d('cake_console', 'Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename),
631: array('y', 'n', 'a'),
632: 'y'
633: );
634: if (strtoupper($response) === 'N') {
635: $response = '';
636: while (!$response) {
637: $response = $this->in(__d('cake_console', "What would you like to name this file?"), null, 'new_' . $filename);
638: $File = new File($this->_output . $response);
639: $filename = $response;
640: }
641: } elseif (strtoupper($response) === 'A') {
642: $overwriteAll = true;
643: }
644: }
645: $File->write($output);
646: $File->close();
647: }
648: }
649: }
650:
651: 652: 653: 654: 655:
656: protected function _writeHeader() {
657: $output = "# LANGUAGE translation of CakePHP Application\n";
658: $output .= "# Copyright YEAR NAME <EMAIL@ADDRESS>\n";
659: $output .= "#\n";
660: $output .= "#, fuzzy\n";
661: $output .= "msgid \"\"\n";
662: $output .= "msgstr \"\"\n";
663: $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
664: $output .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
665: $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
666: $output .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
667: $output .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
668: $output .= "\"MIME-Version: 1.0\\n\"\n";
669: $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
670: $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
671: $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n";
672: return $output;
673: }
674:
675: 676: 677: 678: 679: 680: 681:
682: protected function _getStrings(&$position, $target) {
683: $strings = array();
684: $count = count($strings);
685: while ($count < $target && ($this->_tokens[$position] === ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position][0] == T_LNUMBER)) {
686: $count = count($strings);
687: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') {
688: $string = '';
689: while ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position] === '.') {
690: if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
691: $string .= $this->_formatString($this->_tokens[$position][1]);
692: }
693: $position++;
694: }
695: $strings[] = $string;
696: } elseif ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) {
697: $strings[] = $this->_formatString($this->_tokens[$position][1]);
698: } elseif ($this->_tokens[$position][0] == T_LNUMBER) {
699: $strings[] = $this->_tokens[$position][1];
700: }
701: $position++;
702: }
703: return $strings;
704: }
705:
706: 707: 708: 709: 710: 711:
712: protected function _formatString($string) {
713: $quote = substr($string, 0, 1);
714: $string = substr($string, 1, -1);
715: if ($quote === '"') {
716: $string = stripcslashes($string);
717: } else {
718: $string = strtr($string, array("\\'" => "'", "\\\\" => "\\"));
719: }
720: $string = str_replace("\r\n", "\n", $string);
721: return addcslashes($string, "\0..\37\\\"");
722: }
723:
724: 725: 726: 727: 728: 729: 730: 731: 732:
733: protected function _markerError($file, $line, $marker, $count) {
734: $this->out(__d('cake_console', "Invalid marker content in %s:%s\n* %s(", $file, $line, $marker));
735: $count += 2;
736: $tokenCount = count($this->_tokens);
737: $parenthesis = 1;
738:
739: while ((($tokenCount - $count) > 0) && $parenthesis) {
740: if (is_array($this->_tokens[$count])) {
741: $this->out($this->_tokens[$count][1], false);
742: } else {
743: $this->out($this->_tokens[$count], false);
744: if ($this->_tokens[$count] === '(') {
745: $parenthesis++;
746: }
747:
748: if ($this->_tokens[$count] === ')') {
749: $parenthesis--;
750: }
751: }
752: $count++;
753: }
754: $this->out("\n", true);
755: }
756:
757: 758: 759: 760: 761:
762: protected function _searchFiles() {
763: $pattern = false;
764: if (!empty($this->_exclude)) {
765: $exclude = array();
766: foreach ($this->_exclude as $e) {
767: if (DS !== '\\' && $e[0] !== DS) {
768: $e = DS . $e;
769: }
770: $exclude[] = preg_quote($e, '/');
771: }
772: $pattern = '/' . implode('|', $exclude) . '/';
773: }
774: foreach ($this->_paths as $path) {
775: $Folder = new Folder($path);
776: $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true);
777: if (!empty($pattern)) {
778: foreach ($files as $i => $file) {
779: if (preg_match($pattern, $file)) {
780: unset($files[$i]);
781: }
782: }
783: $files = array_values($files);
784: }
785: $this->_files = array_merge($this->_files, $files);
786: }
787: }
788:
789: 790: 791: 792: 793: 794:
795: protected function _isExtractingApp() {
796: return $this->_paths === array(APP);
797: }
798:
799: 800: 801: 802: 803: 804:
805: protected function _isPathUsable($path) {
806: return is_dir($path) && is_writable($path);
807: }
808: }
809: