diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php new file mode 100644 index 000000000000..e5147093d2dc --- /dev/null +++ b/system/Commands/Translation/LocalizationFinder.php @@ -0,0 +1,357 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Translation; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Commands\Translation\LocalizationFinder\ArrayHelper; +use Config\App; +use Locale; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +/** + * @see \CodeIgniter\Commands\Translation\LocalizationFinderTest + */ +class LocalizationFinder extends BaseCommand +{ + protected $group = 'Translation'; + protected $name = 'lang:find'; + protected $description = 'Find and save available phrases to translate.'; + protected $usage = 'lang:find [options]'; + protected $arguments = []; + protected $options = [ + '--locale' => 'Specify locale (en, ru, etc.) to save files.', + '--dir' => 'Directory to search for translations relative to APPPATH.', + '--show-new' => 'Show only new translations in table. Does not write to files.', + '--verbose' => 'Output detailed information.', + ]; + + /** + * Flag for output detailed information + */ + private bool $verbose = false; + + /** + * Flag for showing only translations, without saving + */ + private bool $showNew = false; + + private string $languagePath; + + public function run(array $params) + { + $this->verbose = array_key_exists('verbose', $params); + $this->showNew = array_key_exists('show-new', $params); + $optionLocale = $params['locale'] ?? null; + $optionDir = $params['dir'] ?? null; + $currentLocale = Locale::getDefault(); + $currentDir = APPPATH; + $this->languagePath = $currentDir . 'Language'; + + if (ENVIRONMENT === 'testing') { + $currentDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR; + $this->languagePath = SUPPORTPATH . 'Language'; + } + + if (is_string($optionLocale)) { + if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) { + CLI::error( + 'Error: "' . $optionLocale . '" is not supported. Supported locales: ' + . implode(', ', config(App::class)->supportedLocales) + ); + + return EXIT_USER_INPUT; + } + + $currentLocale = $optionLocale; + } + + if (is_string($optionDir)) { + $tempCurrentDir = realpath($currentDir . $optionDir); + + if (false === $tempCurrentDir) { + CLI::error('Error: Directory must be located in "' . $currentDir . '"'); + + return EXIT_USER_INPUT; + } + + if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) { + CLI::error('Error: Directory "' . $this->languagePath . '" restricted to scan.'); + + return EXIT_USER_INPUT; + } + + $currentDir = $tempCurrentDir; + } + + $this->process($currentDir, $currentLocale); + + CLI::write('All operations done!'); + + return EXIT_SUCCESS; + } + + private function process(string $currentDir, string $currentLocale): void + { + $tableRows = []; + $countNewKeys = 0; + + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir)); + $files = iterator_to_array($iterator, true); + ksort($files); + + [$foundLanguageKeys, $countFiles] = $this->findLanguageKeysInFiles($files); + ksort($foundLanguageKeys); + + $languageDiff = []; + $languageFoundGroups = array_unique(array_keys($foundLanguageKeys)); + + foreach ($languageFoundGroups as $langFileName) { + $languageStoredKeys = []; + $languageFilePath = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php'; + + if (is_file($languageFilePath)) { + // Load old localization + $languageStoredKeys = require $languageFilePath; + } + + $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $languageStoredKeys); + $countNewKeys += ArrayHelper::recursiveCount($languageDiff); + + if ($this->showNew) { + $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows); + } else { + $newLanguageKeys = array_replace_recursive($foundLanguageKeys[$langFileName], $languageStoredKeys); + + if ($languageDiff !== []) { + if (false === file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys))) { + $this->writeIsVerbose('Lang file ' . $langFileName . ' (error write).', 'red'); + } else { + $this->writeIsVerbose('Lang file "' . $langFileName . '" successful updated!', 'green'); + } + } + } + } + + if ($this->showNew && $tableRows !== []) { + sort($tableRows); + CLI::table($tableRows, ['File', 'Key']); + } + + if (! $this->showNew && $countNewKeys > 0) { + CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red'); + } + + $this->writeIsVerbose('Files found: ' . $countFiles); + $this->writeIsVerbose('New translates found: ' . $countNewKeys); + } + + /** + * @param SplFileInfo|string $file + */ + private function findTranslationsInFile($file): array + { + $foundLanguageKeys = []; + + if (is_string($file) && is_file($file)) { + $file = new SplFileInfo($file); + } + + $fileContent = file_get_contents($file->getRealPath()); + preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches); + + if ($matches[1] === []) { + return []; + } + + foreach ($matches[1] as $phraseKey) { + $phraseKeys = explode('.', $phraseKey); + + // Language key not have Filename or Lang key + if (count($phraseKeys) < 2) { + continue; + } + + $languageFileName = array_shift($phraseKeys); + $isEmptyNestedArray = ($languageFileName !== '' && $phraseKeys[0] === '') + || ($languageFileName === '' && $phraseKeys[0] !== '') + || ($languageFileName === '' && $phraseKeys[0] === ''); + + if ($isEmptyNestedArray) { + continue; + } + + if (count($phraseKeys) === 1) { + $foundLanguageKeys[$languageFileName][$phraseKeys[0]] = $phraseKey; + } else { + $childKeys = $this->buildMultiArray($phraseKeys, $phraseKey); + + $foundLanguageKeys[$languageFileName] = array_replace_recursive($foundLanguageKeys[$languageFileName] ?? [], $childKeys); + } + } + + return $foundLanguageKeys; + } + + private function isIgnoredFile(SplFileInfo $file): bool + { + if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) { + return true; + } + + return $file->getExtension() !== 'php'; + } + + private function templateFile(array $language = []): string + { + if ($language !== []) { + $languageArrayString = var_export($language, true); + + $code = <<replaceArraySyntax($code); + } + + return <<<'PHP' + $token) { + if (is_array($token)) { + [$tokenId, $tokenValue] = $token; + + // Replace "array (" + if ( + $tokenId === T_ARRAY + && $tokens[$i + 1][0] === T_WHITESPACE + && $tokens[$i + 2] === '(' + ) { + $newTokens[$i][1] = '['; + $newTokens[$i + 1][1] = ''; + $newTokens[$i + 2] = ''; + } + + // Replace indent + if ($tokenId === T_WHITESPACE && preg_match('/\n([ ]+)/u', $tokenValue, $matches)) { + $newTokens[$i][1] = "\n{$matches[1]}{$matches[1]}"; + } + } // Replace ")" + elseif ($token === ')') { + $newTokens[$i] = ']'; + } + } + + $output = ''; + + foreach ($newTokens as $token) { + $output .= $token[1] ?? $token; + } + + return $output; + } + + /** + * Create multidimensional array from another keys + */ + private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array + { + $newArray = []; + $lastIndex = array_pop($fromKeys); + $current = &$newArray; + + foreach ($fromKeys as $value) { + $current[$value] = []; + $current = &$current[$value]; + } + + $current[$lastIndex] = $lastArrayValue; + + return $newArray; + } + + /** + * Convert multi arrays to specific CLI table rows (flat array) + */ + private function arrayToTableRows(string $langFileName, array $array): array + { + $rows = []; + + foreach ($array as $value) { + if (is_array($value)) { + $rows = array_merge($rows, $this->arrayToTableRows($langFileName, $value)); + + continue; + } + + if (is_string($value)) { + $rows[] = [$langFileName, $value]; + } + } + + return $rows; + } + + /** + * Show details in the console if the flag is set + */ + private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void + { + if ($this->verbose) { + CLI::write($text, $foreground, $background); + } + } + + private function isSubDirectory(string $directory, string $rootDirectory): bool + { + return 0 === strncmp($directory, $rootDirectory, strlen($directory)); + } + + /** + * @param SplFileInfo[] $files + * + * @return array + * @phpstan-return list{0: array>, 1: int} + */ + private function findLanguageKeysInFiles(array $files): array + { + $foundLanguageKeys = []; + $countFiles = 0; + + foreach ($files as $file) { + if ($this->isIgnoredFile($file)) { + continue; + } + + $this->writeIsVerbose('File found: ' . mb_substr($file->getRealPath(), mb_strlen(APPPATH))); + $countFiles++; + $foundLanguageKeys = array_replace_recursive($this->findTranslationsInFile($file), $foundLanguageKeys); + } + + return [$foundLanguageKeys, $countFiles]; + } +} diff --git a/system/Commands/Translation/LocalizationFinder/ArrayHelper.php b/system/Commands/Translation/LocalizationFinder/ArrayHelper.php new file mode 100644 index 000000000000..d747127c3a42 --- /dev/null +++ b/system/Commands/Translation/LocalizationFinder/ArrayHelper.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Translation\LocalizationFinder; + +class ArrayHelper +{ + /** + * Compare recursively two associative arrays and return difference as new array. + * Returns keys that exist in `$original` but not in `$compareWith`. + */ + public static function recursiveDiff(array $original, array $compareWith): array + { + $difference = []; + + if ($original === []) { + return []; + } + + if ($compareWith === []) { + return $original; + } + + foreach ($original as $originalKey => $originalValue) { + if ($originalValue === []) { + continue; + } + + if (is_array($originalValue)) { + $diffArrays = []; + + if (isset($compareWith[$originalKey]) && is_array($compareWith[$originalKey])) { + $diffArrays = self::recursiveDiff($originalValue, $compareWith[$originalKey]); + } else { + $difference[$originalKey] = $originalValue; + } + + if ($diffArrays !== []) { + $difference[$originalKey] = $diffArrays; + } + } elseif (is_string($originalValue) && ! array_key_exists($originalKey, $compareWith)) { + $difference[$originalKey] = $originalValue; + } + } + + return $difference; + } + + /** + * Recursively count all keys. + */ + public static function recursiveCount(array $array, int $counter = 0): int + { + foreach ($array as $value) { + if (is_array($value)) { + $counter = self::recursiveCount($value, $counter); + } + + $counter++; + } + + return $counter; + } +} diff --git a/tests/_support/Services/Translation/TranslationNested/TranslationFour.php b/tests/_support/Services/Translation/TranslationNested/TranslationFour.php new file mode 100644 index 000000000000..545e821bb049 --- /dev/null +++ b/tests/_support/Services/Translation/TranslationNested/TranslationFour.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Services\Translation\Nested; + +class TranslationFour +{ + public function list() + { + $translationOne1 = lang('TranslationOne.title'); + $translationOne5 = lang('TranslationOne.last_operation_success'); + + $translationThree1 = lang('TranslationThree.alerts.created'); + $translationThree2 = lang('TranslationThree.alerts.failed_insert'); + + $translationThree5 = lang('TranslationThree.formFields.new.name'); + $translationThree7 = lang('TranslationThree.formFields.new.short_tag'); + + $translationFour1 = lang('Translation-Four.dashed.key-with-dash'); + $translationFour2 = lang('Translation-Four.dashed.key-with-dash-two'); + } +} diff --git a/tests/_support/Services/Translation/TranslationOne.php b/tests/_support/Services/Translation/TranslationOne.php new file mode 100644 index 000000000000..bf6b0e1e31c1 --- /dev/null +++ b/tests/_support/Services/Translation/TranslationOne.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Services\Translation; + +class TranslationOne +{ + public function list() + { + $translationOne1 = lang('TranslationOne.title'); + $translationOne2 = lang('TranslationOne.DESCRIPTION'); + $translationOne3 = lang('TranslationOne.metaTags'); + $translationOne4 = lang('TranslationOne.Copyright'); + $translationOne5 = lang('TranslationOne.last_operation_success'); + + $translationThree1 = lang('TranslationThree.alerts.created'); + $translationThree2 = lang('TranslationThree.alerts.failed_insert'); + $translationThree3 = lang('TranslationThree.alerts.Updated'); + $translationThree4 = lang('TranslationThree.alerts.DELETED'); + + $translationThree5 = lang('TranslationThree.formFields.new.name'); + $translationThree6 = lang('TranslationThree.formFields.new.TEXT'); + $translationThree7 = lang('TranslationThree.formFields.new.short_tag'); + $translationThree8 = lang('TranslationThree.formFields.edit.name'); + $translationThree9 = lang('TranslationThree.formFields.edit.TEXT'); + $translationThree10 = lang('TranslationThree.formFields.edit.short_tag'); + } +} diff --git a/tests/_support/Services/Translation/TranslationThree.php b/tests/_support/Services/Translation/TranslationThree.php new file mode 100644 index 000000000000..d9da9ba67c75 --- /dev/null +++ b/tests/_support/Services/Translation/TranslationThree.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Services\Translation; + +class TranslationThree +{ + public function list() + { + $translationOne1 = lang('TranslationOne.title'); + $translationOne2 = lang('TranslationOne.DESCRIPTION'); + $translationOne6 = lang('TranslationOne.subTitle'); + $translationOne7 = lang('TranslationOne.overflow_style'); + + $translationThree1 = lang('TranslationThree.alerts.created'); + $translationThree2 = lang('TranslationThree.alerts.failed_insert'); + + $translationThree5 = lang('TranslationThree.formFields.new.name'); + $translationThree6 = lang('TranslationThree.formFields.new.TEXT'); + $translationThree7 = lang('TranslationThree.formFields.new.short_tag'); + + $translationThree11 = lang('TranslationThree.alerts.CANCELED'); + $translationThree12 = lang('TranslationThree.alerts.missing_keys'); + + $translationThree13 = lang('TranslationThree.formErrors.edit.empty_name'); + $translationThree14 = lang('TranslationThree.formErrors.edit.INVALID_TEXT'); + $translationThree15 = lang('TranslationThree.formErrors.edit.missing_short_tag'); + } +} diff --git a/tests/_support/Services/Translation/TranslationTwo.php b/tests/_support/Services/Translation/TranslationTwo.php new file mode 100644 index 000000000000..71e5e663b27e --- /dev/null +++ b/tests/_support/Services/Translation/TranslationTwo.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Services\Translation; + +class TranslationTwo +{ + public function list() + { + $langKey = 'TranslationTwo.error_key'; + + // Error language keys + $translationError1 = lang('TranslationTwo'); + $translationError2 = lang(' '); + $translationError3 = lang(''); + $translationError4 = lang('.invalid_key'); + $translationError5 = lang('TranslationTwo.'); + $translationError6 = lang('TranslationTwo...'); + $translationError7 = lang('..invalid_nested_key..'); + + // Empty in comments lang('') lang(' ') + } +} diff --git a/tests/system/Commands/Translation/LocalizationFinder/ArrayHelperTest.php b/tests/system/Commands/Translation/LocalizationFinder/ArrayHelperTest.php new file mode 100644 index 000000000000..499d092521fe --- /dev/null +++ b/tests/system/Commands/Translation/LocalizationFinder/ArrayHelperTest.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\TranslationLocalizationFinder; + +use CodeIgniter\Commands\Translation\LocalizationFinder\ArrayHelper; +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @group Others + * + * @internal + */ +final class ArrayHelperTest extends CIUnitTestCase +{ + private array $compareWith; + + protected function setUp(): void + { + $this->compareWith = [ + 'a' => [ + 'b' => [ + 'c' => [ + 'd' => 'value1', + ], + ], + 'e' => 'value2', + 'f' => [ + 'g' => 'value3', + 'h' => 'value4', + 'i' => [ + 'j' => 'value5', + ], + ], + 'k' => null, + 'l' => [], + 'm' => '', + ], + ]; + } + + public function testRecursiveDiffCopy(): void + { + $this->assertSame([], ArrayHelper::recursiveDiff($this->compareWith, $this->compareWith)); + } + + public function testRecursiveDiffShuffleCopy(): void + { + $original = [ + 'a' => [ + 'l' => [], + 'k' => null, + 'e' => 'value2', + 'f' => [ + 'i' => [ + 'j' => 'value5', + ], + 'h' => 'value4', + 'g' => 'value3', + ], + 'm' => '', + 'b' => [ + 'c' => [ + 'd' => 'value1', + ], + ], + ], + ]; + + $this->assertSame([], ArrayHelper::recursiveDiff($original, $this->compareWith)); + } + + public function testRecursiveDiffCopyWithAnotherValues(): void + { + $original = [ + 'a' => [ + 'b' => [ + 'c' => [ + 'd' => 'value1_1', + ], + ], + 'e' => 'value2_2', + 'f' => [ + 'g' => 'value3_3', + 'h' => 'value4_4', + 'i' => [ + 'j' => 'value5_5', + ], + ], + 'k' => [], + 'l' => null, + 'm' => 'value6_6', + ], + ]; + + $this->assertSame([], ArrayHelper::recursiveDiff($original, $this->compareWith)); + } + + public function testRecursiveDiffEmptyCompare(): void + { + $this->assertSame($this->compareWith, ArrayHelper::recursiveDiff($this->compareWith, [])); + } + + public function testRecursiveDiffEmptyOriginal(): void + { + $this->assertSame([], ArrayHelper::recursiveDiff([], $this->compareWith)); + } + + public function testRecursiveDiffCompletelyDifferent(): void + { + $original = [ + 'new_a' => [ + 'new_b' => [ + 'new_c' => [ + 'new_d' => 'value1_1', + ], + ], + 'new_e' => 'value2_2', + 'new_f' => [ + 'new_g' => 'value3_3', + 'new_h' => 'value4_4', + 'new_i' => [ + 'new_j' => 'value5_5', + ], + ], + 'new_k' => [], + 'new_l' => null, + 'new_m' => '', + ], + ]; + + $this->assertSame($original, ArrayHelper::recursiveDiff($original, $this->compareWith)); + } + + public function testRecursiveDiffPartlyDifferent(): void + { + $original = [ + 'a' => [ + 'b' => [ + 'new_c' => [ + 'd' => 'value1', + ], + ], + 'e' => 'value2', + 'f' => [ + 'g' => 'value3', + 'new_h' => 'value4', + 'i' => [ + 'new_j' => 'value5', + ], + ], + 'k' => null, + 'new_l' => [], + 'm' => [ + 'new_n' => '', + ], + ], + ]; + + $diff = [ + 'a' => [ + 'b' => [ + 'new_c' => [ + 'd' => 'value1', + ], + ], + 'f' => [ + 'new_h' => 'value4', + 'i' => [ + 'new_j' => 'value5', + ], + ], + 'm' => [ + 'new_n' => '', + ], + ], + ]; + + $this->assertSame($diff, ArrayHelper::recursiveDiff($original, $this->compareWith)); + } + + public function testRecursiveCountSimple(): void + { + $array = [ + 'a' => 'value1', + 'b' => 'value2', + 'c' => 'value3', + ]; + + $this->assertSame(3, ArrayHelper::recursiveCount($array)); + } + + public function testRecursiveCountNested(): void + { + $array = [ + 'a' => 'value1', + 'b' => [ + 'c' => 'value2', + ], + 'd' => 'value3', + 'e' => [ + 'f' => [ + 'g' => 'value4', + 'h' => [ + 'i' => 'value5', + ], + ], + ], + 'j' => [], + 'k' => null, + ]; + + $this->assertSame(11, ArrayHelper::recursiveCount($array)); + } + + public function testRecursiveCountEqualEmpty(): void + { + $array = [ + 'root' => [ + 'a' => [], + 'b' => false, + 'c' => null, + 'd' => '', + 'e' => 0, + ], + ]; + + $this->assertSame(6, ArrayHelper::recursiveCount($array)); + } +} diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php new file mode 100644 index 000000000000..f5cc38ec7b2f --- /dev/null +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -0,0 +1,234 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Translation; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use Config\App; +use Config\Services; +use Locale; + +/** + * @group Others + * + * @internal + */ +final class LocalizationFinderTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private static string $locale; + private static string $languageTestPath; + + protected function setUp(): void + { + parent::setUp(); + self::$locale = Locale::getDefault(); + self::$languageTestPath = SUPPORTPATH . 'Language' . DIRECTORY_SEPARATOR; + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->clearGeneratedFiles(); + } + + public function testUpdateDefaultLocale(): void + { + $this->makeLocaleDirectory(); + + command('lang:find --dir Translation'); + + $this->assertTranslationsExistAndHaveTranslatedKeys(); + } + + public function testUpdateWithLocaleOption(): void + { + self::$locale = config(App::class)->supportedLocales[0]; + $this->makeLocaleDirectory(); + + command('lang:find --dir Translation --locale ' . self::$locale); + + $this->assertTranslationsExistAndHaveTranslatedKeys(); + } + + public function testUpdateWithIncorrectLocaleOption(): void + { + self::$locale = 'test_locale_incorrect'; + $this->makeLocaleDirectory(); + + $status = Services::commands()->run('lang:find', [ + 'dir' => 'Translation', + 'locale' => self::$locale, + ]); + + $this->assertSame(EXIT_USER_INPUT, $status); + } + + public function testUpdateWithEmptyDirOption(): void + { + $this->makeLocaleDirectory(); + + command('lang:find'); + + $this->assertTranslationsExistAndHaveTranslatedKeys(); + } + + public function testUpdateWithIncorrectDirOption(): void + { + $this->makeLocaleDirectory(); + + $status = Services::commands()->run('lang:find', [ + 'dir' => 'Translation/NotExistFolder', + ]); + + $this->assertSame(EXIT_USER_INPUT, $status); + } + + public function testShowNewTranslation(): void + { + $this->makeLocaleDirectory(); + + command('lang:find --dir Translation --show-new'); + + $this->assertStringContainsString($this->getActualTableWithNewKeys(), $this->getStreamFilterBuffer()); + } + + private function getActualTranslationOneKeys(): array + { + return [ + 'title' => 'TranslationOne.title', + 'DESCRIPTION' => 'TranslationOne.DESCRIPTION', + 'subTitle' => 'TranslationOne.subTitle', + 'overflow_style' => 'TranslationOne.overflow_style', + 'metaTags' => 'TranslationOne.metaTags', + 'Copyright' => 'TranslationOne.Copyright', + 'last_operation_success' => 'TranslationOne.last_operation_success', + ]; + } + + private function getActualTranslationThreeKeys(): array + { + return [ + 'alerts' => [ + 'created' => 'TranslationThree.alerts.created', + 'failed_insert' => 'TranslationThree.alerts.failed_insert', + 'CANCELED' => 'TranslationThree.alerts.CANCELED', + 'missing_keys' => 'TranslationThree.alerts.missing_keys', + 'Updated' => 'TranslationThree.alerts.Updated', + 'DELETED' => 'TranslationThree.alerts.DELETED', + ], + 'formFields' => [ + 'new' => [ + 'name' => 'TranslationThree.formFields.new.name', + 'TEXT' => 'TranslationThree.formFields.new.TEXT', + 'short_tag' => 'TranslationThree.formFields.new.short_tag', + ], + 'edit' => [ + 'name' => 'TranslationThree.formFields.edit.name', + 'TEXT' => 'TranslationThree.formFields.edit.TEXT', + 'short_tag' => 'TranslationThree.formFields.edit.short_tag', + ], + ], + 'formErrors' => [ + 'edit' => [ + 'empty_name' => 'TranslationThree.formErrors.edit.empty_name', + 'INVALID_TEXT' => 'TranslationThree.formErrors.edit.INVALID_TEXT', + 'missing_short_tag' => 'TranslationThree.formErrors.edit.missing_short_tag', + ], + ], + ]; + } + + private function getActualTranslationFourKeys(): array + { + return [ + 'dashed' => [ + 'key-with-dash' => 'Translation-Four.dashed.key-with-dash', + 'key-with-dash-two' => 'Translation-Four.dashed.key-with-dash-two', + ], + ]; + } + + private function getActualTableWithNewKeys(): string + { + return <<<'TEXT_WRAP' + +------------------+----------------------------------------------------+ + | File | Key | + +------------------+----------------------------------------------------+ + | Translation-Four | Translation-Four.dashed.key-with-dash | + | Translation-Four | Translation-Four.dashed.key-with-dash-two | + | TranslationOne | TranslationOne.Copyright | + | TranslationOne | TranslationOne.DESCRIPTION | + | TranslationOne | TranslationOne.last_operation_success | + | TranslationOne | TranslationOne.metaTags | + | TranslationOne | TranslationOne.overflow_style | + | TranslationOne | TranslationOne.subTitle | + | TranslationOne | TranslationOne.title | + | TranslationThree | TranslationThree.alerts.CANCELED | + | TranslationThree | TranslationThree.alerts.DELETED | + | TranslationThree | TranslationThree.alerts.Updated | + | TranslationThree | TranslationThree.alerts.created | + | TranslationThree | TranslationThree.alerts.failed_insert | + | TranslationThree | TranslationThree.alerts.missing_keys | + | TranslationThree | TranslationThree.formErrors.edit.INVALID_TEXT | + | TranslationThree | TranslationThree.formErrors.edit.empty_name | + | TranslationThree | TranslationThree.formErrors.edit.missing_short_tag | + | TranslationThree | TranslationThree.formFields.edit.TEXT | + | TranslationThree | TranslationThree.formFields.edit.name | + | TranslationThree | TranslationThree.formFields.edit.short_tag | + | TranslationThree | TranslationThree.formFields.new.TEXT | + | TranslationThree | TranslationThree.formFields.new.name | + | TranslationThree | TranslationThree.formFields.new.short_tag | + +------------------+----------------------------------------------------+ + TEXT_WRAP; + } + + private function assertTranslationsExistAndHaveTranslatedKeys(): void + { + $this->assertFileExists(self::$languageTestPath . self::$locale . '/TranslationOne.php'); + $this->assertFileExists(self::$languageTestPath . self::$locale . '/TranslationThree.php'); + $this->assertFileExists(self::$languageTestPath . self::$locale . '/Translation-Four.php'); + + $translationOneKeys = require self::$languageTestPath . self::$locale . '/TranslationOne.php'; + $translationThreeKeys = require self::$languageTestPath . self::$locale . '/TranslationThree.php'; + $translationFourKeys = require self::$languageTestPath . self::$locale . '/Translation-Four.php'; + + $this->assertSame($translationOneKeys, $this->getActualTranslationOneKeys()); + $this->assertSame($translationThreeKeys, $this->getActualTranslationThreeKeys()); + $this->assertSame($translationFourKeys, $this->getActualTranslationFourKeys()); + } + + private function makeLocaleDirectory(): void + { + @mkdir(self::$languageTestPath . self::$locale, 0777, true); + } + + private function clearGeneratedFiles(): void + { + if (is_file(self::$languageTestPath . self::$locale . '/TranslationOne.php')) { + unlink(self::$languageTestPath . self::$locale . '/TranslationOne.php'); + } + + if (is_file(self::$languageTestPath . self::$locale . '/TranslationThree.php')) { + unlink(self::$languageTestPath . self::$locale . '/TranslationThree.php'); + } + + if (is_file(self::$languageTestPath . self::$locale . '/Translation-Four.php')) { + unlink(self::$languageTestPath . self::$locale . '/Translation-Four.php'); + } + + if (is_dir(self::$languageTestPath . '/test_locale_incorrect')) { + rmdir(self::$languageTestPath . '/test_locale_incorrect'); + } + } +} diff --git a/user_guide_src/source/changelogs/v4.5.0.rst b/user_guide_src/source/changelogs/v4.5.0.rst index d18f0884e0ce..29c31dcabf8e 100644 --- a/user_guide_src/source/changelogs/v4.5.0.rst +++ b/user_guide_src/source/changelogs/v4.5.0.rst @@ -47,6 +47,8 @@ Enhancements Commands ======== +- Added ``spark lang:find`` command to update translations keys. See :ref:`generating-translation-files-via-command` for the details. + Testing ======= diff --git a/user_guide_src/source/outgoing/localization.rst b/user_guide_src/source/outgoing/localization.rst index f84e9b029004..837807989b0d 100644 --- a/user_guide_src/source/outgoing/localization.rst +++ b/user_guide_src/source/outgoing/localization.rst @@ -254,3 +254,44 @@ project: The translated messages will be automatically picked up because the translations folders get mapped appropriately. + +.. _generating-translation-files-via-command: + +Generating Translation Files via Command +======================================== + +.. versionadded:: 4.5.0 + +You can automatically generate and update translation files in your **app** folder. The command will search for the use of the ``lang()`` function, combine the current translation keys in **app/Language** by defining the locale ``defaultLocale`` from ``Config\App``. +After the operation, you need to translate the language keys yourself. +The command is able to recognize nested keys normally ``File.array.nested.text``. +Previously saved keys do not change. + +.. code-block:: console + + php spark lang:find + +.. literalinclude:: localization/019.php + +.. note:: When the command scans folders, **app/Language** will be skipped. + +The language files generated will most likely not conform to your coding standards. +It is recommended to format them. For example, run ``vendor/bin/php-cs-fixer fix ./app/Language`` if ``php-cs-fixer`` is installed. + +Before updating, it is possible to preview the translations found by the command: + +.. code-block:: console + + php spark lang:find --verbose --show-new + +For a more accurate search, specify the desired locale or directory to scan. + +.. code-block:: console + + php spark lang:find --dir Controllers/Translation --locale en --show-new + +Detailed information can be found by running the command: + +.. code-block:: console + + php spark lang:find --help diff --git a/user_guide_src/source/outgoing/localization/019.php b/user_guide_src/source/outgoing/localization/019.php new file mode 100644 index 000000000000..5de7d9a80731 --- /dev/null +++ b/user_guide_src/source/outgoing/localization/019.php @@ -0,0 +1,13 @@ + [ + 'success' => 'Text.info.success', + ], + 'paragraph' => 'Text.paragraph', +];