diff --git a/system/Commands/Translation/LocalizationSync.php b/system/Commands/Translation/LocalizationSync.php new file mode 100644 index 000000000000..b2614ab3ca2e --- /dev/null +++ b/system/Commands/Translation/LocalizationSync.php @@ -0,0 +1,202 @@ + + * + * 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\Exceptions\LogicException; +use Config\App; +use ErrorException; +use FilesystemIterator; +use Locale; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +/** + * @see \CodeIgniter\Commands\Translation\LocalizationSyncTest + */ +class LocalizationSync extends BaseCommand +{ + protected $group = 'Translation'; + protected $name = 'lang:sync'; + protected $description = 'Synchronize translation files from one language to another.'; + protected $usage = 'lang:sync [options]'; + protected $arguments = []; + protected $options = [ + '--locale' => 'The original locale (en, ru, etc.).', + '--target' => 'Target locale (en, ru, etc.).', + ]; + private string $languagePath; + + public function run(array $params) + { + $optionTargetLocale = ''; + $optionLocale = $params['locale'] ?? Locale::getDefault(); + $this->languagePath = APPPATH . 'Language'; + + if (isset($params['target']) && $params['target'] !== '') { + $optionTargetLocale = $params['target']; + } + + 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; + } + + if ($optionTargetLocale === '') { + CLI::error( + 'Error: "--target" is not configured. Supported locales: ' + . implode(', ', config(App::class)->supportedLocales) + ); + + return EXIT_USER_INPUT; + } + + if (! in_array($optionTargetLocale, config(App::class)->supportedLocales, true)) { + CLI::error( + 'Error: "' . $optionTargetLocale . '" is not supported. Supported locales: ' + . implode(', ', config(App::class)->supportedLocales) + ); + + return EXIT_USER_INPUT; + } + + if ($optionTargetLocale === $optionLocale) { + CLI::error( + 'Error: You cannot have the same values for "--target" and "--locale".' + ); + + return EXIT_USER_INPUT; + } + + if (ENVIRONMENT === 'testing') { + $this->languagePath = SUPPORTPATH . 'Language'; + } + + if ($this->process($optionLocale, $optionTargetLocale) === EXIT_ERROR) { + return EXIT_ERROR; + } + + CLI::write('All operations done!'); + + return EXIT_SUCCESS; + } + + private function process(string $originalLocale, string $targetLocale): int + { + $originalLocaleDir = $this->languagePath . DIRECTORY_SEPARATOR . $originalLocale; + $targetLocaleDir = $this->languagePath . DIRECTORY_SEPARATOR . $targetLocale; + + if (! is_dir($originalLocaleDir)) { + CLI::error( + 'Error: The "' . clean_path($originalLocaleDir) . '" directory was not found.' + ); + + return EXIT_ERROR; + } + + // Unifying the error - mkdir() may cause an exception. + try { + if (! is_dir($targetLocaleDir) && ! mkdir($targetLocaleDir, 0775)) { + throw new ErrorException(); + } + } catch (ErrorException $e) { + CLI::error( + 'Error: The target directory "' . clean_path($targetLocaleDir) . '" cannot be accessed.' + ); + + return EXIT_ERROR; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( + $originalLocaleDir, + FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS + ) + ); + + /** + * @var array $files + */ + $files = iterator_to_array($iterator, true); + ksort($files); + + foreach ($files as $originalLanguageFile) { + if ($originalLanguageFile->getExtension() !== 'php') { + continue; + } + + $targetLanguageFile = $targetLocaleDir . DIRECTORY_SEPARATOR . $originalLanguageFile->getFilename(); + + $targetLanguageKeys = []; + $originalLanguageKeys = include $originalLanguageFile; + + if (is_file($targetLanguageFile)) { + $targetLanguageKeys = include $targetLanguageFile; + } + + $targetLanguageKeys = $this->mergeLanguageKeys($originalLanguageKeys, $targetLanguageKeys, $originalLanguageFile->getBasename('.php')); + + $content = "|string|null> $originalLanguageKeys + * @param array|string|null> $targetLanguageKeys + * + * @return array|string|null> + */ + private function mergeLanguageKeys(array $originalLanguageKeys, array $targetLanguageKeys, string $prefix = ''): array + { + $mergedLanguageKeys = []; + + foreach ($originalLanguageKeys as $key => $value) { + $placeholderValue = $prefix !== '' ? $prefix . '.' . $key : $key; + + if (is_string($value)) { + // Keep the old value + // TODO: The value type may not match the original one + if (array_key_exists($key, $targetLanguageKeys)) { + $mergedLanguageKeys[$key] = $targetLanguageKeys[$key]; + + continue; + } + + // Set new key with placeholder + $mergedLanguageKeys[$key] = $placeholderValue; + } elseif (is_array($value)) { + if (! array_key_exists($key, $targetLanguageKeys)) { + $mergedLanguageKeys[$key] = $this->mergeLanguageKeys($value, [], $placeholderValue); + + continue; + } + + $mergedLanguageKeys[$key] = $this->mergeLanguageKeys($value, $targetLanguageKeys[$key], $placeholderValue); + } else { + throw new LogicException('Value for the key "' . $placeholderValue . '" is of the wrong type. Only "array" or "string" is allowed.'); + } + } + + return $mergedLanguageKeys; + } +} diff --git a/tests/system/Commands/Translation/LocalizationSyncTest.php b/tests/system/Commands/Translation/LocalizationSyncTest.php new file mode 100644 index 000000000000..88d7dc6f3bd6 --- /dev/null +++ b/tests/system/Commands/Translation/LocalizationSyncTest.php @@ -0,0 +1,275 @@ + + * + * 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\Exceptions\LogicException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; +use CodeIgniter\Test\StreamFilterTrait; +use Config\App; +use Locale; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class LocalizationSyncTest extends CIUnitTestCase +{ + use StreamFilterTrait; + use ReflectionHelper; + + private static string $locale; + private static string $languageTestPath; + + /** + * @var array|string|null> + */ + private array $expectedKeys = [ + 'title' => 'Sync.title', + 'status' => [ + 'error' => 'Sync.status.error', + 'done' => 'Sync.status.done', + 'critical' => 'Sync.status.critical', + ], + 'description' => 'Sync.description', + 'empty_array' => [], + 'more' => [ + 'nested' => [ + 'key' => 'Sync.more.nested.key', + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + config(App::class)->supportedLocales = ['en', 'ru', 'de']; + + self::$locale = Locale::getDefault(); + self::$languageTestPath = SUPPORTPATH . 'Language/'; + $this->makeLanguageFiles(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->clearGeneratedFiles(); + } + + public function testSyncDefaultLocale(): void + { + command('lang:sync --target de'); + + $langFile = self::$languageTestPath . 'de/Sync.php'; + + $this->assertFileExists($langFile); + + $langKeys = include $langFile; + + $this->assertIsArray($langKeys); + $this->assertSame($this->expectedKeys, $langKeys); + } + + public function testSyncWithLocaleOption(): void + { + command('lang:sync --locale ru --target de'); + + $langFile = self::$languageTestPath . 'de/Sync.php'; + + $this->assertFileExists($langFile); + + $langKeys = include $langFile; + + $this->assertIsArray($langKeys); + $this->assertSame($this->expectedKeys, $langKeys); + } + + public function testSyncWithExistTranslation(): void + { + // Save old values and add new keys from "en/Sync.php" + // Add value from the old file "de/Sync.php" to new + // Right sort as in "en/Sync.php" + $expectedLangKeys = [ + 'title' => 'Default title (old)', + 'status' => [ + 'error' => 'Error! (old)', + 'done' => 'Sync.status.done', + 'critical' => 'Critical! (old)', + ], + 'description' => 'Sync.description', + 'empty_array' => [], + 'more' => [ + 'nested' => [ + 'key' => 'More nested key... (old)', + ], + ], + ]; + + $lang = <<<'TEXT_WRAP' + [ + 'critical' => 'Critical! (old)', + 'error' => 'Error! (old)', + ], + 'skip' => 'skip this value', + 'title' => 'Default title (old)', + 'more' => [ + 'nested' => [ + 'key' => 'More nested key... (old)', + ], + ], + 'empty_array' => [], + ]; + TEXT_WRAP; + + $langFile = self::$languageTestPath . 'de/Sync.php'; + + mkdir(self::$languageTestPath . 'de', 0755); + file_put_contents($langFile, $lang); + + command('lang:sync --target de'); + + $this->assertFileExists($langFile); + + $langKeys = include $langFile; + + $this->assertIsArray($langKeys); + $this->assertSame($expectedLangKeys, $langKeys); + } + + public function testSyncWithIncorrectLocaleOption(): void + { + command('lang:sync --locale test_locale_incorrect --target de'); + + $this->assertStringContainsString('is not supported', $this->getStreamFilterBuffer()); + } + + public function testSyncWithNullableOriginalLangValue(): void + { + $langWithNullValue = <<<'TEXT_WRAP' + null, + ]; + TEXT_WRAP; + + file_put_contents(self::$languageTestPath . self::$locale . '/SyncInvalid.php', $langWithNullValue); + ob_get_flush(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/Only "array" or "string" is allowed/'); + + command('lang:sync --target de'); + } + + public function testSyncWithIntegerOriginalLangValue(): void + { + $this->resetStreamFilterBuffer(); + + $langWithIntegerValue = <<<'TEXT_WRAP' + 1000, + ]; + TEXT_WRAP; + + file_put_contents(self::$languageTestPath . self::$locale . '/SyncInvalid.php', $langWithIntegerValue); + ob_get_flush(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/Only "array" or "string" is allowed/'); + + command('lang:sync --target de'); + } + + public function testSyncWithIncorrectTargetOption(): void + { + command('lang:sync --locale en --target test_locale_incorrect'); + + $this->assertStringContainsString('is not supported', $this->getStreamFilterBuffer()); + } + + public function testProcessWithInvalidOption(): void + { + $langPath = SUPPORTPATH . 'Language'; + $command = new LocalizationSync(service('logger'), service('commands')); + $this->setPrivateProperty($command, 'languagePath', $langPath); + $runner = $this->getPrivateMethodInvoker($command, 'process'); + + $status = $runner('de', 'jp'); + + $this->assertSame(EXIT_ERROR, $status); + $this->assertStringContainsString('Error: The "ROOTPATH/tests/_support/Language/de" directory was not found.', $this->getStreamFilterBuffer()); + + chmod($langPath, 0544); + $status = $runner('en', 'jp'); + chmod($langPath, 0775); + + $this->assertSame(EXIT_ERROR, $status); + $this->assertStringContainsString('Error: The target directory "ROOTPATH/tests/_support/Language/jp" cannot be accessed.', $this->getStreamFilterBuffer()); + } + + private function makeLanguageFiles(): void + { + $lang = <<<'TEXT_WRAP' + 'Default title', + 'status' => [ + 'error' => 'Error!', + 'done' => 'Done!', + 'critical' => 'Critical!', + ], + 'description' => '', + 'empty_array' => [], + 'more' => [ + 'nested' => [ + 'key' => 'More nested key...', + ], + ], + ]; + TEXT_WRAP; + + file_put_contents(self::$languageTestPath . self::$locale . '/Sync.php', $lang); + file_put_contents(self::$languageTestPath . 'ru/Sync.php', $lang); + } + + private function clearGeneratedFiles(): void + { + $files = [ + self::$languageTestPath . self::$locale . '/Sync.php', + self::$languageTestPath . self::$locale . '/SyncInvalid.php', + self::$languageTestPath . 'ru/Sync.php', + ]; + + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + + if (is_dir(self::$languageTestPath . 'de')) { + delete_files(self::$languageTestPath . 'de'); + rmdir(self::$languageTestPath . 'de'); + } + } +} diff --git a/user_guide_src/source/changelogs/v4.6.0.rst b/user_guide_src/source/changelogs/v4.6.0.rst index d6429bf236e8..c31b628ccf55 100644 --- a/user_guide_src/source/changelogs/v4.6.0.rst +++ b/user_guide_src/source/changelogs/v4.6.0.rst @@ -234,6 +234,7 @@ Commands - The ``spark routes`` and ``spark filter:check`` commands now display filter arguments. - The ``spark filter:check`` command now displays filter classnames. +- The ``spark lang:sync`` command to synchronize translation files. See :ref:`sync-translations-command` Routing ======= diff --git a/user_guide_src/source/outgoing/localization.rst b/user_guide_src/source/outgoing/localization.rst index c5fba00adbc5..dfbc6d3130ac 100644 --- a/user_guide_src/source/outgoing/localization.rst +++ b/user_guide_src/source/outgoing/localization.rst @@ -366,3 +366,29 @@ Detailed information can be found by running the command: .. code-block:: console php spark lang:find --help + +.. _sync-translations-command: + +Synchronization Translation Files via Command +--------------------------------------------- + +.. versionadded:: 4.6.0 + +You may need to create files for another language when you've finished translating for the current language. You can use the spark ``lang:find`` command to help with this. However, it might not detect all translations, particularly those with dynamically set parameters like ``lang('App.status.' . $key, ['payload' => 'John'], 'en')``. + +To ensure no translations are missed, it's best to copy the completed language files and translate them manually. This approach preserves any unique keys the command might have overlooked. + +All you need to do is execute: + +.. code-block:: console + + // Specify the locale for new/updated translations + php spark lang:sync --target ru + + // or set the original locale + php spark lang:sync --locale en --target ru + +As a result, you will receive files with the translation keys. +If there were duplicate keys in the target locale, they are saved. + +.. warning:: Non-matching keys in new translations are deleted!