I18nExtractCommand.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP(tm) Project
  13. * @since 1.2.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Command;
  17. use Cake\Console\Arguments;
  18. use Cake\Console\ConsoleIo;
  19. use Cake\Console\ConsoleOptionParser;
  20. use Cake\Core\App;
  21. use Cake\Core\Configure;
  22. use Cake\Core\Plugin;
  23. use Cake\Utility\Filesystem;
  24. use Cake\Utility\Inflector;
  25. /**
  26. * Language string extractor
  27. */
  28. class I18nExtractCommand extends Command
  29. {
  30. /**
  31. * @inheritDoc
  32. */
  33. public static function defaultName(): string
  34. {
  35. return 'i18n extract';
  36. }
  37. /**
  38. * Paths to use when looking for strings
  39. *
  40. * @var array<string>
  41. */
  42. protected $_paths = [];
  43. /**
  44. * Files from where to extract
  45. *
  46. * @var array<string>
  47. */
  48. protected $_files = [];
  49. /**
  50. * Merge all domain strings into the default.pot file
  51. *
  52. * @var bool
  53. */
  54. protected $_merge = false;
  55. /**
  56. * Current file being processed
  57. *
  58. * @var string
  59. */
  60. protected $_file = '';
  61. /**
  62. * Contains all content waiting to be write
  63. *
  64. * @var array
  65. */
  66. protected $_storage = [];
  67. /**
  68. * Extracted tokens
  69. *
  70. * @var array
  71. */
  72. protected $_tokens = [];
  73. /**
  74. * Extracted strings indexed by domain.
  75. *
  76. * @var array
  77. */
  78. protected $_translations = [];
  79. /**
  80. * Destination path
  81. *
  82. * @var string
  83. */
  84. protected $_output = '';
  85. /**
  86. * An array of directories to exclude.
  87. *
  88. * @var array<string>
  89. */
  90. protected $_exclude = [];
  91. /**
  92. * Holds whether this call should extract the CakePHP Lib messages
  93. *
  94. * @var bool
  95. */
  96. protected $_extractCore = false;
  97. /**
  98. * Displays marker error(s) if true
  99. *
  100. * @var bool
  101. */
  102. protected $_markerError = false;
  103. /**
  104. * Count number of marker errors found
  105. *
  106. * @var int
  107. */
  108. protected $_countMarkerError = 0;
  109. /**
  110. * Method to interact with the user and get path selections.
  111. *
  112. * @param \Cake\Console\ConsoleIo $io The io instance.
  113. * @return void
  114. */
  115. protected function _getPaths(ConsoleIo $io): void
  116. {
  117. /** @psalm-suppress UndefinedConstant */
  118. $defaultPaths = array_merge(
  119. [APP],
  120. App::path('templates'),
  121. ['D'] // This is required to break the loop below
  122. );
  123. $defaultPathIndex = 0;
  124. while (true) {
  125. $currentPaths = count($this->_paths) > 0 ? $this->_paths : ['None'];
  126. $message = sprintf(
  127. "Current paths: %s\nWhat is the path you would like to extract?\n[Q]uit [D]one",
  128. implode(', ', $currentPaths)
  129. );
  130. $response = $io->ask($message, $defaultPaths[$defaultPathIndex] ?? 'D');
  131. if (strtoupper($response) === 'Q') {
  132. $io->err('Extract Aborted');
  133. $this->abort();
  134. return;
  135. }
  136. if (strtoupper($response) === 'D' && count($this->_paths)) {
  137. $io->out();
  138. return;
  139. }
  140. if (strtoupper($response) === 'D') {
  141. $io->warning('No directories selected. Please choose a directory.');
  142. } elseif (is_dir($response)) {
  143. $this->_paths[] = $response;
  144. $defaultPathIndex++;
  145. } else {
  146. $io->err('The directory path you supplied was not found. Please try again.');
  147. }
  148. $io->out();
  149. }
  150. }
  151. /**
  152. * Execute the command
  153. *
  154. * @param \Cake\Console\Arguments $args The command arguments.
  155. * @param \Cake\Console\ConsoleIo $io The console io
  156. * @return int|null The exit code or null for success
  157. */
  158. public function execute(Arguments $args, ConsoleIo $io): ?int
  159. {
  160. $plugin = '';
  161. if ($args->getOption('exclude')) {
  162. $this->_exclude = explode(',', (string)$args->getOption('exclude'));
  163. }
  164. if ($args->getOption('files')) {
  165. $this->_files = explode(',', (string)$args->getOption('files'));
  166. }
  167. if ($args->getOption('paths')) {
  168. $this->_paths = explode(',', (string)$args->getOption('paths'));
  169. } elseif ($args->getOption('plugin')) {
  170. $plugin = Inflector::camelize((string)$args->getOption('plugin'));
  171. $this->_paths = [Plugin::classPath($plugin), Plugin::templatePath($plugin)];
  172. } else {
  173. $this->_getPaths($io);
  174. }
  175. if ($args->hasOption('extract-core')) {
  176. $this->_extractCore = !(strtolower((string)$args->getOption('extract-core')) === 'no');
  177. } else {
  178. $response = $io->askChoice(
  179. 'Would you like to extract the messages from the CakePHP core?',
  180. ['y', 'n'],
  181. 'n'
  182. );
  183. $this->_extractCore = strtolower($response) === 'y';
  184. }
  185. if ($args->hasOption('exclude-plugins') && $this->_isExtractingApp()) {
  186. $this->_exclude = array_merge($this->_exclude, App::path('plugins'));
  187. }
  188. if ($this->_extractCore) {
  189. $this->_paths[] = CAKE;
  190. }
  191. if ($args->hasOption('output')) {
  192. $this->_output = (string)$args->getOption('output');
  193. } elseif ($args->hasOption('plugin')) {
  194. $this->_output = Plugin::path($plugin)
  195. . 'resources' . DIRECTORY_SEPARATOR
  196. . 'locales' . DIRECTORY_SEPARATOR;
  197. } else {
  198. $message = "What is the path you would like to output?\n[Q]uit";
  199. $localePaths = App::path('locales');
  200. if (!$localePaths) {
  201. $localePaths[] = ROOT . 'resources' . DIRECTORY_SEPARATOR . 'locales';
  202. }
  203. while (true) {
  204. $response = $io->ask(
  205. $message,
  206. $localePaths[0]
  207. );
  208. if (strtoupper($response) === 'Q') {
  209. $io->err('Extract Aborted');
  210. return static::CODE_ERROR;
  211. }
  212. if ($this->_isPathUsable($response)) {
  213. $this->_output = $response . DIRECTORY_SEPARATOR;
  214. break;
  215. }
  216. $io->err('');
  217. $io->err(
  218. '<error>The directory path you supplied was ' .
  219. 'not found. Please try again.</error>'
  220. );
  221. $io->err('');
  222. }
  223. }
  224. if ($args->hasOption('merge')) {
  225. $this->_merge = !(strtolower((string)$args->getOption('merge')) === 'no');
  226. } else {
  227. $io->out();
  228. $response = $io->askChoice(
  229. 'Would you like to merge all domain strings into the default.pot file?',
  230. ['y', 'n'],
  231. 'n'
  232. );
  233. $this->_merge = strtolower($response) === 'y';
  234. }
  235. $this->_markerError = (bool)$args->getOption('marker-error');
  236. if (empty($this->_files)) {
  237. $this->_searchFiles();
  238. }
  239. $this->_output = rtrim($this->_output, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
  240. if (!$this->_isPathUsable($this->_output)) {
  241. $io->err(sprintf('The output directory %s was not found or writable.', $this->_output));
  242. return static::CODE_ERROR;
  243. }
  244. $this->_extract($args, $io);
  245. return static::CODE_SUCCESS;
  246. }
  247. /**
  248. * Add a translation to the internal translations property
  249. *
  250. * Takes care of duplicate translations
  251. *
  252. * @param string $domain The domain
  253. * @param string $msgid The message string
  254. * @param array $details Context and plural form if any, file and line references
  255. * @return void
  256. */
  257. protected function _addTranslation(string $domain, string $msgid, array $details = []): void
  258. {
  259. $context = $details['msgctxt'] ?? '';
  260. if (empty($this->_translations[$domain][$msgid][$context])) {
  261. $this->_translations[$domain][$msgid][$context] = [
  262. 'msgid_plural' => false,
  263. ];
  264. }
  265. if (isset($details['msgid_plural'])) {
  266. $this->_translations[$domain][$msgid][$context]['msgid_plural'] = $details['msgid_plural'];
  267. }
  268. if (isset($details['file'])) {
  269. $line = $details['line'] ?? 0;
  270. $this->_translations[$domain][$msgid][$context]['references'][$details['file']][] = $line;
  271. }
  272. }
  273. /**
  274. * Extract text
  275. *
  276. * @param \Cake\Console\Arguments $args The Arguments instance
  277. * @param \Cake\Console\ConsoleIo $io The io instance
  278. * @return void
  279. */
  280. protected function _extract(Arguments $args, ConsoleIo $io): void
  281. {
  282. $io->out();
  283. $io->out();
  284. $io->out('Extracting...');
  285. $io->hr();
  286. $io->out('Paths:');
  287. foreach ($this->_paths as $path) {
  288. $io->out(' ' . $path);
  289. }
  290. $io->out('Output Directory: ' . $this->_output);
  291. $io->hr();
  292. $this->_extractTokens($args, $io);
  293. $this->_buildFiles($args);
  294. $this->_writeFiles($args, $io);
  295. $this->_paths = $this->_files = $this->_storage = [];
  296. $this->_translations = $this->_tokens = [];
  297. $io->out();
  298. if ($this->_countMarkerError) {
  299. $io->err("{$this->_countMarkerError} marker error(s) detected.");
  300. $io->err(' => Use the --marker-error option to display errors.');
  301. }
  302. $io->out('Done.');
  303. }
  304. /**
  305. * Gets the option parser instance and configures it.
  306. *
  307. * @param \Cake\Console\ConsoleOptionParser $parser The parser to configure
  308. * @return \Cake\Console\ConsoleOptionParser
  309. */
  310. public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
  311. {
  312. $parser->setDescription(
  313. 'Extract i18n POT files from application source files. ' .
  314. 'Source files are parsed and string literal format strings ' .
  315. 'provided to the <info>__</info> family of functions are extracted.'
  316. )->addOption('app', [
  317. 'help' => 'Directory where your application is located.',
  318. ])->addOption('paths', [
  319. 'help' => 'Comma separated list of paths that are searched for source files.',
  320. ])->addOption('merge', [
  321. 'help' => 'Merge all domain strings into a single default.po file.',
  322. 'default' => 'no',
  323. 'choices' => ['yes', 'no'],
  324. ])->addOption('output', [
  325. 'help' => 'Full path to output directory.',
  326. ])->addOption('files', [
  327. 'help' => 'Comma separated list of files to parse.',
  328. ])->addOption('exclude-plugins', [
  329. 'boolean' => true,
  330. 'default' => true,
  331. 'help' => 'Ignores all files in plugins if this command is run inside from the same app directory.',
  332. ])->addOption('plugin', [
  333. 'help' => 'Extracts tokens only from the plugin specified and '
  334. . 'puts the result in the plugin\'s `locales` directory.',
  335. 'short' => 'p',
  336. ])->addOption('exclude', [
  337. 'help' => 'Comma separated list of directories to exclude.' .
  338. ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors',
  339. ])->addOption('overwrite', [
  340. 'boolean' => true,
  341. 'default' => false,
  342. 'help' => 'Always overwrite existing .pot files.',
  343. ])->addOption('extract-core', [
  344. 'help' => 'Extract messages from the CakePHP core libraries.',
  345. 'choices' => ['yes', 'no'],
  346. ])->addOption('no-location', [
  347. 'boolean' => true,
  348. 'default' => false,
  349. 'help' => 'Do not write file locations for each extracted message.',
  350. ])->addOption('marker-error', [
  351. 'boolean' => true,
  352. 'default' => false,
  353. 'help' => 'Do not display marker error.',
  354. ]);
  355. return $parser;
  356. }
  357. /**
  358. * Extract tokens out of all files to be processed
  359. *
  360. * @param \Cake\Console\Arguments $args The io instance
  361. * @param \Cake\Console\ConsoleIo $io The io instance
  362. * @return void
  363. */
  364. protected function _extractTokens(Arguments $args, ConsoleIo $io): void
  365. {
  366. /** @var \Cake\Shell\Helper\ProgressHelper $progress */
  367. $progress = $io->helper('progress');
  368. $progress->init(['total' => count($this->_files)]);
  369. $isVerbose = $args->getOption('verbose');
  370. $functions = [
  371. '__' => ['singular'],
  372. '__n' => ['singular', 'plural'],
  373. '__d' => ['domain', 'singular'],
  374. '__dn' => ['domain', 'singular', 'plural'],
  375. '__x' => ['context', 'singular'],
  376. '__xn' => ['context', 'singular', 'plural'],
  377. '__dx' => ['domain', 'context', 'singular'],
  378. '__dxn' => ['domain', 'context', 'singular', 'plural'],
  379. ];
  380. $pattern = '/(' . implode('|', array_keys($functions)) . ')\s*\(/';
  381. foreach ($this->_files as $file) {
  382. $this->_file = $file;
  383. if ($isVerbose) {
  384. $io->verbose(sprintf('Processing %s...', $file));
  385. }
  386. $code = file_get_contents($file);
  387. if (preg_match($pattern, $code) === 1) {
  388. $allTokens = token_get_all($code);
  389. $this->_tokens = [];
  390. foreach ($allTokens as $token) {
  391. if (!is_array($token) || ($token[0] !== T_WHITESPACE && $token[0] !== T_INLINE_HTML)) {
  392. $this->_tokens[] = $token;
  393. }
  394. }
  395. unset($allTokens);
  396. foreach ($functions as $functionName => $map) {
  397. $this->_parse($io, $functionName, $map);
  398. }
  399. }
  400. if (!$isVerbose) {
  401. $progress->increment(1);
  402. $progress->draw();
  403. }
  404. }
  405. }
  406. /**
  407. * Parse tokens
  408. *
  409. * @param \Cake\Console\ConsoleIo $io The io instance
  410. * @param string $functionName Function name that indicates translatable string (e.g: '__')
  411. * @param array $map Array containing what variables it will find (e.g: domain, singular, plural)
  412. * @return void
  413. */
  414. protected function _parse(ConsoleIo $io, string $functionName, array $map): void
  415. {
  416. $count = 0;
  417. $tokenCount = count($this->_tokens);
  418. while ($tokenCount - $count > 1) {
  419. $countToken = $this->_tokens[$count];
  420. $firstParenthesis = $this->_tokens[$count + 1];
  421. if (!is_array($countToken)) {
  422. $count++;
  423. continue;
  424. }
  425. [$type, $string, $line] = $countToken;
  426. if (($type === T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) {
  427. $position = $count;
  428. $depth = 0;
  429. while (!$depth) {
  430. if ($this->_tokens[$position] === '(') {
  431. $depth++;
  432. } elseif ($this->_tokens[$position] === ')') {
  433. $depth--;
  434. }
  435. $position++;
  436. }
  437. $mapCount = count($map);
  438. $strings = $this->_getStrings($position, $mapCount);
  439. if ($mapCount === count($strings)) {
  440. $singular = '';
  441. $plural = $context = null;
  442. extract(array_combine($map, $strings));
  443. $domain = $domain ?? 'default';
  444. $details = [
  445. 'file' => $this->_file,
  446. 'line' => $line,
  447. ];
  448. $details['file'] = '.' . str_replace(ROOT, '', $details['file']);
  449. if ($plural !== null) {
  450. $details['msgid_plural'] = $plural;
  451. }
  452. if ($context !== null) {
  453. $details['msgctxt'] = $context;
  454. }
  455. $this->_addTranslation($domain, $singular, $details);
  456. } else {
  457. $this->_markerError($io, $this->_file, $line, $functionName, $count);
  458. }
  459. }
  460. $count++;
  461. }
  462. }
  463. /**
  464. * Build the translate template file contents out of obtained strings
  465. *
  466. * @param \Cake\Console\Arguments $args Console arguments
  467. * @return void
  468. */
  469. protected function _buildFiles(Arguments $args): void
  470. {
  471. $paths = $this->_paths;
  472. /** @psalm-suppress UndefinedConstant */
  473. $paths[] = realpath(APP) . DIRECTORY_SEPARATOR;
  474. usort($paths, function (string $a, string $b) {
  475. return strlen($a) - strlen($b);
  476. });
  477. foreach ($this->_translations as $domain => $translations) {
  478. foreach ($translations as $msgid => $contexts) {
  479. foreach ($contexts as $context => $details) {
  480. $plural = $details['msgid_plural'];
  481. $files = $details['references'];
  482. $header = '';
  483. if (!$args->getOption('no-location')) {
  484. $occurrences = [];
  485. foreach ($files as $file => $lines) {
  486. $lines = array_unique($lines);
  487. foreach ($lines as $line) {
  488. $occurrences[] = $file . ':' . $line;
  489. }
  490. }
  491. $occurrences = implode("\n#: ", $occurrences);
  492. $header = '#: '
  493. . str_replace(DIRECTORY_SEPARATOR, '/', $occurrences)
  494. . "\n";
  495. }
  496. $sentence = '';
  497. if ($context !== '') {
  498. $sentence .= "msgctxt \"{$context}\"\n";
  499. }
  500. if ($plural === false) {
  501. $sentence .= "msgid \"{$msgid}\"\n";
  502. $sentence .= "msgstr \"\"\n\n";
  503. } else {
  504. $sentence .= "msgid \"{$msgid}\"\n";
  505. $sentence .= "msgid_plural \"{$plural}\"\n";
  506. $sentence .= "msgstr[0] \"\"\n";
  507. $sentence .= "msgstr[1] \"\"\n\n";
  508. }
  509. if ($domain !== 'default' && $this->_merge) {
  510. $this->_store('default', $header, $sentence);
  511. } else {
  512. $this->_store($domain, $header, $sentence);
  513. }
  514. }
  515. }
  516. }
  517. }
  518. /**
  519. * Prepare a file to be stored
  520. *
  521. * @param string $domain The domain
  522. * @param string $header The header content.
  523. * @param string $sentence The sentence to store.
  524. * @return void
  525. */
  526. protected function _store(string $domain, string $header, string $sentence): void
  527. {
  528. $this->_storage[$domain] = $this->_storage[$domain] ?? [];
  529. if (!isset($this->_storage[$domain][$sentence])) {
  530. $this->_storage[$domain][$sentence] = $header;
  531. } else {
  532. $this->_storage[$domain][$sentence] .= $header;
  533. }
  534. }
  535. /**
  536. * Write the files that need to be stored
  537. *
  538. * @param \Cake\Console\Arguments $args The command arguments.
  539. * @param \Cake\Console\ConsoleIo $io The console io
  540. * @return void
  541. */
  542. protected function _writeFiles(Arguments $args, ConsoleIo $io): void
  543. {
  544. $io->out();
  545. $overwriteAll = false;
  546. if ($args->getOption('overwrite')) {
  547. $overwriteAll = true;
  548. }
  549. foreach ($this->_storage as $domain => $sentences) {
  550. $output = $this->_writeHeader($domain);
  551. $headerLength = strlen($output);
  552. foreach ($sentences as $sentence => $header) {
  553. $output .= $header . $sentence;
  554. }
  555. // Remove vendor prefix if present.
  556. $slashPosition = strpos($domain, '/');
  557. if ($slashPosition !== false) {
  558. $domain = substr($domain, $slashPosition + 1);
  559. }
  560. $filename = str_replace('/', '_', $domain) . '.pot';
  561. $outputPath = $this->_output . $filename;
  562. if ($this->checkUnchanged($outputPath, $headerLength, $output) === true) {
  563. $io->out($filename . ' is unchanged. Skipping.');
  564. continue;
  565. }
  566. $response = '';
  567. while ($overwriteAll === false && file_exists($outputPath) && strtoupper($response) !== 'Y') {
  568. $io->out();
  569. $response = $io->askChoice(
  570. sprintf('Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename),
  571. ['y', 'n', 'a'],
  572. 'y'
  573. );
  574. if (strtoupper($response) === 'N') {
  575. $response = '';
  576. while (!$response) {
  577. $response = $io->ask('What would you like to name this file?', 'new_' . $filename);
  578. $filename = $response;
  579. }
  580. } elseif (strtoupper($response) === 'A') {
  581. $overwriteAll = true;
  582. }
  583. }
  584. $fs = new Filesystem();
  585. $fs->dumpFile($this->_output . $filename, $output);
  586. }
  587. }
  588. /**
  589. * Build the translation template header
  590. *
  591. * @param string $domain Domain
  592. * @return string Translation template header
  593. */
  594. protected function _writeHeader(string $domain): string
  595. {
  596. $projectIdVersion = $domain === 'cake' ? 'CakePHP ' . Configure::version() : 'PROJECT VERSION';
  597. $output = "# LANGUAGE translation of CakePHP Application\n";
  598. $output .= "# Copyright YEAR NAME <EMAIL@ADDRESS>\n";
  599. $output .= "#\n";
  600. $output .= "#, fuzzy\n";
  601. $output .= "msgid \"\"\n";
  602. $output .= "msgstr \"\"\n";
  603. $output .= '"Project-Id-Version: ' . $projectIdVersion . "\\n\"\n";
  604. $output .= '"POT-Creation-Date: ' . date('Y-m-d H:iO') . "\\n\"\n";
  605. $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
  606. $output .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
  607. $output .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
  608. $output .= "\"MIME-Version: 1.0\\n\"\n";
  609. $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
  610. $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
  611. $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n";
  612. return $output;
  613. }
  614. /**
  615. * Check whether the old and new output are the same, thus unchanged
  616. *
  617. * Compares the sha1 hashes of the old and new file without header.
  618. *
  619. * @param string $oldFile The existing file.
  620. * @param int $headerLength The length of the file header in bytes.
  621. * @param string $newFileContent The content of the new file.
  622. * @return bool Whether or not the old and new file are unchanged.
  623. */
  624. protected function checkUnchanged(string $oldFile, int $headerLength, string $newFileContent): bool
  625. {
  626. if (!file_exists($oldFile)) {
  627. return false;
  628. }
  629. $oldFileContent = file_get_contents($oldFile);
  630. $oldChecksum = sha1(substr($oldFileContent, $headerLength));
  631. $newChecksum = sha1(substr($newFileContent, $headerLength));
  632. return $oldChecksum === $newChecksum;
  633. }
  634. /**
  635. * Get the strings from the position forward
  636. *
  637. * @param int $position Actual position on tokens array
  638. * @param int $target Number of strings to extract
  639. * @return array Strings extracted
  640. */
  641. protected function _getStrings(int &$position, int $target): array
  642. {
  643. $strings = [];
  644. $count = count($strings);
  645. while (
  646. $count < $target
  647. && ($this->_tokens[$position] === ','
  648. || $this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING
  649. || $this->_tokens[$position][0] === T_LNUMBER
  650. )
  651. ) {
  652. $count = count($strings);
  653. if ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') {
  654. $string = '';
  655. while (
  656. $this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING
  657. || $this->_tokens[$position] === '.'
  658. ) {
  659. if ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING) {
  660. $string .= $this->_formatString($this->_tokens[$position][1]);
  661. }
  662. $position++;
  663. }
  664. $strings[] = $string;
  665. } elseif ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING) {
  666. $strings[] = $this->_formatString($this->_tokens[$position][1]);
  667. } elseif ($this->_tokens[$position][0] === T_LNUMBER) {
  668. $strings[] = $this->_tokens[$position][1];
  669. }
  670. $position++;
  671. }
  672. return $strings;
  673. }
  674. /**
  675. * Format a string to be added as a translatable string
  676. *
  677. * @param string $string String to format
  678. * @return string Formatted string
  679. */
  680. protected function _formatString(string $string): string
  681. {
  682. $quote = substr($string, 0, 1);
  683. $string = substr($string, 1, -1);
  684. if ($quote === '"') {
  685. $string = stripcslashes($string);
  686. } else {
  687. $string = strtr($string, ["\\'" => "'", '\\\\' => '\\']);
  688. }
  689. $string = str_replace("\r\n", "\n", $string);
  690. return addcslashes($string, "\0..\37\\\"");
  691. }
  692. /**
  693. * Indicate an invalid marker on a processed file
  694. *
  695. * @param \Cake\Console\ConsoleIo $io The io instance.
  696. * @param string $file File where invalid marker resides
  697. * @param int $line Line number
  698. * @param string $marker Marker found
  699. * @param int $count Count
  700. * @return void
  701. */
  702. protected function _markerError($io, string $file, int $line, string $marker, int $count): void
  703. {
  704. if (strpos($this->_file, CAKE_CORE_INCLUDE_PATH) === false) {
  705. $this->_countMarkerError++;
  706. }
  707. if (!$this->_markerError) {
  708. return;
  709. }
  710. $io->err(sprintf("Invalid marker content in %s:%s\n* %s(", $file, $line, $marker));
  711. $count += 2;
  712. $tokenCount = count($this->_tokens);
  713. $parenthesis = 1;
  714. while (($tokenCount - $count > 0) && $parenthesis) {
  715. if (is_array($this->_tokens[$count])) {
  716. $io->err($this->_tokens[$count][1], 0);
  717. } else {
  718. $io->err($this->_tokens[$count], 0);
  719. if ($this->_tokens[$count] === '(') {
  720. $parenthesis++;
  721. }
  722. if ($this->_tokens[$count] === ')') {
  723. $parenthesis--;
  724. }
  725. }
  726. $count++;
  727. }
  728. $io->err("\n");
  729. }
  730. /**
  731. * Search files that may contain translatable strings
  732. *
  733. * @return void
  734. */
  735. protected function _searchFiles(): void
  736. {
  737. $pattern = false;
  738. if (!empty($this->_exclude)) {
  739. $exclude = [];
  740. foreach ($this->_exclude as $e) {
  741. if (DIRECTORY_SEPARATOR !== '\\' && $e[0] !== DIRECTORY_SEPARATOR) {
  742. $e = DIRECTORY_SEPARATOR . $e;
  743. }
  744. $exclude[] = preg_quote($e, '/');
  745. }
  746. $pattern = '/' . implode('|', $exclude) . '/';
  747. }
  748. foreach ($this->_paths as $path) {
  749. $path = realpath($path) . DIRECTORY_SEPARATOR;
  750. $fs = new Filesystem();
  751. $files = $fs->findRecursive($path, '/\.php$/');
  752. $files = array_keys(iterator_to_array($files));
  753. sort($files);
  754. if (!empty($pattern)) {
  755. $files = preg_grep($pattern, $files, PREG_GREP_INVERT);
  756. $files = array_values($files);
  757. }
  758. $this->_files = array_merge($this->_files, $files);
  759. }
  760. $this->_files = array_unique($this->_files);
  761. }
  762. /**
  763. * Returns whether this execution is meant to extract string only from directories in folder represented by the
  764. * APP constant, i.e. this task is extracting strings from same application.
  765. *
  766. * @return bool
  767. */
  768. protected function _isExtractingApp(): bool
  769. {
  770. /** @psalm-suppress UndefinedConstant */
  771. return $this->_paths === [APP];
  772. }
  773. /**
  774. * Checks whether or not a given path is usable for writing.
  775. *
  776. * @param string $path Path to folder
  777. * @return bool true if it exists and is writable, false otherwise
  778. */
  779. protected function _isPathUsable($path): bool
  780. {
  781. if (!is_dir($path)) {
  782. mkdir($path, 0770, true);
  783. }
  784. return is_dir($path) && is_writable($path);
  785. }
  786. }