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