I18nExtractCommand.php 30 KB

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