diff --git a/UPGRADE-5.3.md b/UPGRADE-5.3.md index be36db0c14..e1e910e729 100644 --- a/UPGRADE-5.3.md +++ b/UPGRADE-5.3.md @@ -44,6 +44,8 @@ FrameworkBundle * Deprecate the `KernelTestCase::$container` property, use `KernelTestCase::getContainer()` instead * Rename the container parameter `profiler_listener.only_master_requests` to `profiler_listener.only_main_requests` * Deprecate registering workflow services as public + * Deprecate option `--xliff-version` of the `translation:update` command, use e.g. `--format=xlf20` instead + * Deprecate option `--output-format` of the `translation:update` command, use e.g. `--format=xlf20` instead HttpFoundation -------------- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 68f1bf9419..25640765da 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -86,6 +86,8 @@ FrameworkBundle * Removed the `lock.RESOURCE_NAME` and `lock.RESOURCE_NAME.store` services and the `lock`, `LockInterface`, `lock.store` and `PersistingStoreInterface` aliases, use `lock.RESOURCE_NAME.factory`, `lock.factory` or `LockFactory` instead. * Remove the `KernelTestCase::$container` property, use `KernelTestCase::getContainer()` instead * Registered workflow services are now private + * Remove option `--xliff-version` of the `translation:update` command, use e.g. `--output-format=xlf20` instead + * Remove option `--output-format` of the `translation:update` command, use e.g. `--output-format=xlf20` instead HttpFoundation -------------- diff --git a/link b/link index a56075840e..29f9600d6b 100755 --- a/link +++ b/link @@ -41,7 +41,7 @@ if (!is_dir("$pathToProject/vendor/symfony")) { $sfPackages = array('symfony/symfony' => __DIR__); $filesystem = new Filesystem(); -$braces = array('Bundle', 'Bridge', 'Component', 'Component/Security', 'Component/Mailer/Bridge', 'Component/Messenger/Bridge', 'Component/Notifier/Bridge', 'Contracts'); +$braces = array('Bundle', 'Bridge', 'Component', 'Component/Security', 'Component/Mailer/Bridge', 'Component/Messenger/Bridge', 'Component/Notifier/Bridge', 'Contracts', 'Component/Translation/Bridge'); $directories = array_merge(...array_values(array_map(function ($part) { return glob(__DIR__.'/src/Symfony/'.$part.'/*', GLOB_ONLYDIR | GLOB_NOSORT); }, $braces))); diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index d2ce954777..27ce0f6b81 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -20,6 +20,8 @@ CHANGELOG * Rename the container parameter `profiler_listener.only_master_requests` to `profiler_listener.only_main_requests` * Add service `fragment.uri_generator` to generate the URI of a fragment * Deprecate registering workflow services as public + * Deprecate option `--xliff-version` of the `translation:update` command, use e.g. `--format=xlf20` instead + * Deprecate option `--output-format` of the `translation:update` command, use e.g. `--format=xlf20` instead 5.2.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php index cacf328451..c849538173 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -77,12 +77,13 @@ class TranslationUpdateCommand extends Command new InputArgument('locale', InputArgument::REQUIRED, 'The locale'), new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'), new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'), - new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf'), + new InputOption('output-format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format (deprecated)'), + new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'), new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'), new InputOption('force', null, InputOption::VALUE_NONE, 'Should the update be done'), new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'), new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to update'), - new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version', '1.2'), + new InputOption('xliff-version', null, InputOption::VALUE_OPTIONAL, 'Override the default xliff version (deprecated)'), new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically', 'asc'), new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'), ]) @@ -112,8 +113,8 @@ You can sort the output with the --sort flag: You can dump a tree-like structure using the yaml format with --as-tree flag: - php %command.full_name% --force --output-format=yaml --as-tree=3 en AcmeBundle - php %command.full_name% --force --output-format=yaml --sort=asc --as-tree=3 fr + php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle + php %command.full_name% --force --format=yaml --sort=asc --as-tree=3 fr EOF ) @@ -135,13 +136,31 @@ EOF return 1; } + $format = $input->getOption('output-format') ?: $input->getOption('format'); + $xliffVersion = $input->getOption('xliff-version') ?? '1.2'; + + if ($input->getOption('xliff-version')) { + trigger_deprecation('symfony/framework-bundle', '5.3', 'The "--xliff-version" option is deprecated, use "--format=xlf%d" instead.', 10 * $xliffVersion); + } + + if ($input->getOption('output-format')) { + trigger_deprecation('symfony/framework-bundle', '5.3', 'The "--output-format" option is deprecated, use "--format=xlf%d" instead.', 10 * $xliffVersion); + } + + switch ($format) { + case 'xlf20': $xliffVersion = '2.0'; + // no break + case 'xlf12': $format = 'xlf'; + } + // check format $supportedFormats = $this->writer->getFormats(); - if (!\in_array($input->getOption('output-format'), $supportedFormats, true)) { - $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).'.']); + if (!\in_array($format, $supportedFormats, true)) { + $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']); return 1; } + /** @var KernelInterface $kernel */ $kernel = $this->getApplication()->getKernel(); @@ -225,23 +244,7 @@ EOF $resultMessage = 'Translation files were successfully updated'; - // move new messages to intl domain when possible - if (class_exists(\MessageFormatter::class)) { - foreach ($operation->getDomains() as $domain) { - $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; - $newMessages = $operation->getNewMessages($domain); - - if ([] === $newMessages || ([] === $currentCatalogue->all($intlDomain) && [] !== $currentCatalogue->all($domain))) { - continue; - } - - $result = $operation->getResult(); - $allIntlMessages = $result->all($intlDomain); - $currentMessages = array_diff_key($newMessages, $result->all($domain)); - $result->replace($currentMessages, $domain); - $result->replace($allIntlMessages + $newMessages, $intlDomain); - } - } + $operation->moveMessagesToIntlDomainsIfPossible('new'); // show compiled list of messages if (true === $input->getOption('dump-messages')) { @@ -284,8 +287,8 @@ EOF $extractedMessagesCount += $domainMessagesCount; } - if ('xlf' === $input->getOption('output-format')) { - $io->comment(sprintf('Xliff output version is %s', $input->getOption('xliff-version'))); + if ('xlf' === $format) { + $io->comment(sprintf('Xliff output version is %s', $xliffVersion)); } $resultMessage = sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was'); @@ -306,7 +309,7 @@ EOF $bundleTransPath = end($transPaths); } - $this->writer->write($operation->getResult(), $input->getOption('output-format'), ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $input->getOption('xliff-version'), 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]); + $this->writer->write($operation->getResult(), $format, ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $xliffVersion, 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]); if (true === $input->getOption('dump-messages')) { $resultMessage .= ' and translation files were updated'; @@ -335,11 +338,13 @@ EOF foreach ($catalogue->getResources() as $resource) { $filteredCatalogue->addResource($resource); } + if ($metadata = $catalogue->getMetadata('', $intlDomain)) { foreach ($metadata as $k => $v) { $filteredCatalogue->setMetadata($k, $v, $intlDomain); } } + if ($metadata = $catalogue->getMetadata('', $domain)) { foreach ($metadata as $k => $v) { $filteredCatalogue->setMetadata($k, $v, $domain); diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 3590d0074c..5558e3cfe8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -85,6 +85,7 @@ class UnusedTagsPass implements CompilerPassInterface 'translation.dumper', 'translation.extractor', 'translation.loader', + 'translation.provider_factory', 'twig.extension', 'twig.loader', 'twig.runtime', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index a49f29ef3b..2f0d24dd85 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -785,6 +785,7 @@ class Configuration implements ConfigurationInterface ->fixXmlConfig('fallback') ->fixXmlConfig('path') ->fixXmlConfig('enabled_locale') + ->fixXmlConfig('provider') ->children() ->arrayNode('fallbacks') ->info('Defaults to the value of "default_locale".') @@ -822,6 +823,27 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->arrayNode('providers') + ->info('Translation providers you can read/write your translations from') + ->useAttributeAsKey('name') + ->prototype('array') + ->fixXmlConfig('domain') + ->fixXmlConfig('locale') + ->children() + ->scalarNode('dsn')->end() + ->arrayNode('domains') + ->prototype('scalar')->end() + ->defaultValue([]) + ->end() + ->arrayNode('locales') + ->prototype('scalar')->end() + ->defaultValue([]) + ->info('If not set, all locales listed under framework.translator.enabled_locales are used.') + ->end() + ->end() + ->end() + ->defaultValue([]) + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 7e37224962..9cddc28f86 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -169,6 +169,7 @@ use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; +use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1222,11 +1223,14 @@ class FrameworkExtension extends Extension if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition('console.command.translation_debug'); $container->removeDefinition('console.command.translation_update'); + $container->removeDefinition('console.command.translation_pull'); + $container->removeDefinition('console.command.translation_push'); return; } $loader->load('translation.php'); + $loader->load('translation_providers.php'); // Use the "real" translator instead of the identity default $container->setAlias('translator', 'translator.default')->setPublic(true); @@ -1348,6 +1352,46 @@ class FrameworkExtension extends Extension $options, ]); } + + $classToServices = [ + LocoProviderFactory::class => 'translation.provider_factory.loco', + ]; + + $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; + + foreach ($classToServices as $class => $service) { + $package = sprintf('symfony/%s-translation', substr($service, \strlen('translation.provider_factory.'))); + + if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable($package, $class, $parentPackages)) { + $container->removeDefinition($service); + } + } + + if (!$config['providers']) { + return; + } + + foreach ($config['providers'] as $name => $provider) { + if (!$config['enabled_locales'] && !$provider['locales']) { + throw new LogicException(sprintf('You must specify one of "framework.translator.enabled_locales" or "framework.translator.providers.%s.locales" in order to use translation providers.', $name)); + } + } + + $container->getDefinition('console.command.translation_pull') + ->replaceArgument(4, array_merge($transPaths, [$config['default_path']])) + ->replaceArgument(5, $config['enabled_locales']) + ; + + $container->getDefinition('console.command.translation_push') + ->replaceArgument(2, array_merge($transPaths, [$config['default_path']])) + ->replaceArgument(3, $config['enabled_locales']) + ; + + $container->getDefinition('translation.provider_collection_factory') + ->replaceArgument(1, $config['enabled_locales']) + ; + + $container->getDefinition('translation.provider_collection')->setArgument(0, $config['providers']); } private function registerValidationConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $propertyInfoEnabled) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 1c05d8760e..c076183cdc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -46,6 +46,8 @@ use Symfony\Component\Messenger\Command\FailedMessagesRetryCommand; use Symfony\Component\Messenger\Command\FailedMessagesShowCommand; use Symfony\Component\Messenger\Command\SetupTransportsCommand; use Symfony\Component\Messenger\Command\StopWorkersCommand; +use Symfony\Component\Translation\Command\TranslationPullCommand; +use Symfony\Component\Translation\Command\TranslationPushCommand; use Symfony\Component\Translation\Command\XliffLintCommand; use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand; @@ -232,6 +234,26 @@ return static function (ContainerConfigurator $container) { ]) ->tag('console.command') + ->set('console.command.translation_pull', TranslationPullCommand::class) + ->args([ + service('translation.provider_collection'), + service('translation.writer'), + service('translation.reader'), + param('kernel.default_locale'), + [], // Translator paths + [], // Enabled locales + ]) + ->tag('console.command', ['command' => 'translation:pull']) + + ->set('console.command.translation_push', TranslationPushCommand::class) + ->args([ + service('translation.provider_collection'), + service('translation.reader'), + [], // Translator paths + [], // Enabled locales + ]) + ->tag('console.command', ['command' => 'translation:push']) + ->set('console.command.workflow_dump', WorkflowDumpCommand::class) ->tag('console.command') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 44ba965b79..edcaca3a90 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -176,6 +176,7 @@ + @@ -195,6 +196,15 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php new file mode 100644 index 0000000000..ffc70033fc --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory; +use Symfony\Component\Translation\Provider\NullProviderFactory; +use Symfony\Component\Translation\Provider\TranslationProviderCollection; +use Symfony\Component\Translation\Provider\TranslationProviderCollectionFactory; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('translation.provider_collection', TranslationProviderCollection::class) + ->factory([service('translation.provider_collection_factory'), 'fromConfig']) + ->args([ + [], // Providers + ]) + + ->set('translation.provider_collection_factory', TranslationProviderCollectionFactory::class) + ->args([ + tagged_iterator('translation.provider_factory'), + [], // Enabled locales + ]) + + ->set('translation.provider_factory.null', NullProviderFactory::class) + ->tag('translation.provider_factory') + + ->set('translation.provider_factory.loco', LocoProviderFactory::class) + ->args([ + service('http_client'), + service('logger'), + param('kernel.default_locale'), + service('translation.loader.xliff'), + ]) + ->tag('translation.provider_factory') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index efa0acf856..b056ed498e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -418,6 +418,7 @@ class ConfigurationTest extends TestCase 'parse_html' => false, 'localizable_html_attributes' => [], ], + 'providers' => [], ], 'validation' => [ 'enabled' => !class_exists(FullStack::class), diff --git a/src/Symfony/Component/Translation/Bridge/Loco/.gitattributes b/src/Symfony/Component/Translation/Bridge/Loco/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Translation/Bridge/Loco/.gitignore b/src/Symfony/Component/Translation/Bridge/Loco/.gitignore new file mode 100644 index 0000000000..76367ee5bb --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/src/Symfony/Component/Translation/Bridge/Loco/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/Loco/CHANGELOG.md new file mode 100644 index 0000000000..bbb9efcaeb --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Create the bridge diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LICENSE b/src/Symfony/Component/Translation/Bridge/Loco/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProvider.php new file mode 100644 index 0000000000..8b315cbd76 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProvider.php @@ -0,0 +1,245 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Loco\Provider; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\ProviderException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + * + * In Loco: + * * Tags refers to Symfony's translation domains + * * Assets refers to Symfony's translation keys + * * Translations refers to Symfony's translated messages + * + * @experimental in 5.3 + */ +final class LocoProvider implements ProviderInterface +{ + private $client; + private $loader; + private $logger; + private $defaultLocale; + private $endpoint; + + public function __construct(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint) + { + $this->client = $client; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->endpoint = $endpoint; + } + + public function __toString(): string + { + return sprintf('%s://%s', LocoProviderFactory::SCHEME, $this->endpoint); + } + + public function write(TranslatorBagInterface $translatorBag): void + { + $catalogue = $translatorBag->getCatalogue($this->defaultLocale); + + if (!$catalogue) { + $catalogue = $translatorBag->getCatalogues()[0]; + } + + // Create keys on Loco + foreach ($catalogue->all() as $domain => $messages) { + $ids = []; + foreach ($messages as $id => $message) { + $ids[] = $id; + $this->createAsset($id); + } + if ($ids) { + $this->tagsAssets($ids, $domain); + } + } + + // Push translations in all locales and tag them with domain + foreach ($translatorBag->getCatalogues() as $catalogue) { + $locale = $catalogue->getLocale(); + + if (!\in_array($locale, $this->getLocales())) { + $this->createLocale($locale); + } + + foreach ($catalogue->all() as $messages) { + foreach ($messages as $id => $message) { + $this->translateAsset($id, $message, $locale); + } + } + } + } + + public function read(array $domains, array $locales): TranslatorBag + { + $domains = $domains ?: ['*']; + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + foreach ($domains as $domain) { + $response = $this->client->request('GET', sprintf('export/locale/%s.xlf?filter=%s&status=translated', $locale, $domain)); + + if (404 === $response->getStatusCode()) { + $this->logger->error(sprintf('Locale "%s" for domain "%s" does not exist in Loco.', $locale, $domain)); + continue; + } + + $responseContent = $response->getContent(false); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException('Unable to read the Loco response: '.$responseContent, $response); + } + + $translatorBag->addCatalogue($this->loader->load($responseContent, $locale, $domain)); + } + } + + return $translatorBag; + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $deletedIds = []; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->all() as $messages) { + foreach ($messages as $id => $message) { + if (\in_array($id, $deletedIds, true)) { + continue; + } + + $this->deleteAsset($id); + $deletedIds[] = $id; + } + } + } + } + + private function createAsset(string $id): void + { + $response = $this->client->request('POST', 'assets', [ + 'body' => [ + 'name' => $id, + 'id' => $id, + 'type' => 'text', + 'default' => 'untranslated', + ], + ]); + + if (409 === $response->getStatusCode()) { + $this->logger->info(sprintf('Translation key "%s" already exists in Loco.', $id), [ + 'id' => $id, + ]); + } elseif (201 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to add new translation key "%s" to Loco: (status code: "%s") "%s".', $id, $response->getStatusCode(), $response->getContent(false))); + } + } + + private function translateAsset(string $id, string $message, string $locale): void + { + $response = $this->client->request('POST', sprintf('translations/%s/%s', $id, $locale), [ + 'body' => $message, + ]); + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to add translation message "%s" (for key: "%s" in locale "%s") to Loco: "%s".', $message, $id, $locale, $response->getContent(false))); + } + } + + private function tagsAssets(array $ids, string $tag): void + { + $idsAsString = implode(',', array_unique($ids)); + + if (!\in_array($tag, $this->getTags(), true)) { + $this->createTag($tag); + } + + $response = $this->client->request('POST', sprintf('tags/%s.json', $tag), [ + 'body' => $idsAsString, + ]); + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to add tag "%s" on translation keys "%s" to Loco: "%s".', $tag, $idsAsString, $response->getContent(false))); + } + } + + private function createTag(string $tag): void + { + $response = $this->client->request('POST', 'tags.json', [ + 'body' => [ + 'name' => $tag, + ], + ]); + + if (201 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to create tag "%s" on Loco: "%s".', $tag, $response->getContent(false))); + } + } + + private function getTags(): array + { + $response = $this->client->request('GET', 'tags.json'); + $content = $response->toArray(false); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to get tags on Loco: "%s".', $response->getContent(false)), $response); + } + + return $content ?: []; + } + + private function createLocale(string $locale): void + { + $response = $this->client->request('POST', 'locales', [ + 'body' => [ + 'code' => $locale, + ], + ]); + + if (201 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to create locale "%s" on Loco: "%s".', $locale, $response->getContent(false))); + } + } + + private function getLocales(): array + { + $response = $this->client->request('GET', 'locales'); + $content = $response->toArray(false); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to get locales on Loco: "%s".', $response->getContent(false)), $response); + } + + return array_reduce($content, function ($carry, $locale) { + $carry[] = $locale['code']; + + return $carry; + }, []); + } + + private function deleteAsset(string $id): void + { + $response = $this->client->request('DELETE', sprintf('assets/%s.json', $id)); + + if (200 !== $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to delete translation key "%s" to Loco: "%s".', $id, $response->getContent(false))); + } + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProviderFactory.php new file mode 100644 index 0000000000..06cb0c71cc --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/Provider/LocoProviderFactory.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Loco\Provider; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class LocoProviderFactory extends AbstractProviderFactory +{ + public const SCHEME = 'loco'; + private const HOST = 'localise.biz/api/'; + + private $client; + private $logger; + private $defaultLocale; + private $loader; + + public function __construct(HttpClientInterface $client, LoggerInterface $logger, string $defaultLocale, LoaderInterface $loader) + { + $this->client = $client; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->loader = $loader; + } + + /** + * @return LocoProvider + */ + public function create(Dsn $dsn): ProviderInterface + { + if (self::SCHEME !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, self::SCHEME, $this->getSupportedSchemes()); + } + + $endpoint = sprintf('%s%s', 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(), $dsn->getPort() ? ':'.$dsn->getPort() : ''); + $client = $this->client->withOptions([ + 'base_uri' => 'https://'.$endpoint, + 'headers' => [ + 'Authorization' => 'Loco '.$this->getUser($dsn), + ], + ]); + + return new LocoProvider($client, $this->loader, $this->logger, $this->defaultLocale, $endpoint); + } + + protected function getSupportedSchemes(): array + { + return [self::SCHEME]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/README.md b/src/Symfony/Component/Translation/Bridge/Loco/README.md new file mode 100644 index 0000000000..2624f3329d --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/README.md @@ -0,0 +1,25 @@ +Loco Translation Provider +========================= + +Provides Loco integration for Symfony Translation. + +DSN example +----------- + +``` +// .env file +LOCO_DSN=loco://API_KEY@default +``` + +where: + - `API_KEY` is your Loco project API key + +[more information on Loco website](https://localise.biz/help/developers/api-keys) + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderFactoryTest.php b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderFactoryTest.php new file mode 100644 index 0000000000..f108a58da7 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderFactoryTest.php @@ -0,0 +1,39 @@ +getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader()); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php new file mode 100644 index 0000000000..5ea577fb67 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php @@ -0,0 +1,516 @@ +createMock(ResponseInterface::class); + $createAssetResponse->expects($this->exactly(4)) + ->method('getStatusCode') + ->willReturn(201); + + $getLocalesResponse = $this->createMock(ResponseInterface::class); + $getLocalesResponse->expects($this->exactly(4)) + ->method('getStatusCode') + ->willReturn(200); + $getLocalesResponse->expects($this->exactly(2)) + ->method('getContent') + ->with(false) + ->willReturn('[{"code":"en"}]'); + + $createLocaleResponse = $this->createMock(ResponseInterface::class); + $createLocaleResponse->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(201); + + $translateAssetResponse = $this->createMock(ResponseInterface::class); + $translateAssetResponse->expects($this->exactly(8)) + ->method('getStatusCode') + ->willReturn(200); + + $getTagsEmptyResponse = $this->createMock(ResponseInterface::class); + $getTagsEmptyResponse->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $getTagsEmptyResponse->expects($this->once()) + ->method('getContent') + ->with(false) + ->willReturn('[]'); + + $getTagsNotEmptyResponse = $this->createMock(ResponseInterface::class); + $getTagsNotEmptyResponse->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + $getTagsNotEmptyResponse->expects($this->once()) + ->method('getContent') + ->with(false) + ->willReturn('["messages"]'); + + $createTagResponse = $this->createMock(ResponseInterface::class); + $createTagResponse->expects($this->exactly(4)) + ->method('getStatusCode') + ->willReturn(201); + + $tagAssetResponse = $this->createMock(ResponseInterface::class); + $tagAssetResponse->expects($this->exactly(4)) + ->method('getStatusCode') + ->willReturn(200); + + $expectedAuthHeader = 'Authorization: Loco API_KEY'; + + $responses = [ + 'createAsset1' => function (string $method, string $url, array $options = []) use ($createAssetResponse, $expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'name' => 'a', + 'id' => 'a', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertEquals('POST', $method); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals($expectedBody, $options['body']); + + return $createAssetResponse; + }, + 'getTags1' => function (string $method, string $url, array $options = []) use ($getTagsEmptyResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('GET', $method); + $this->assertEquals('https://localise.biz/api/tags.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return $getTagsEmptyResponse; + }, + 'createTag1' => function (string $method, string $url, array $options = []) use ($createTagResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/tags.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals(http_build_query(['name' => 'messages']), $options['body']); + + return $createTagResponse; + }, + 'tagAsset1' => function (string $method, string $url, array $options = []) use ($tagAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/tags/messages.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('a', $options['body']); + + return $tagAssetResponse; + }, + 'createAsset2' => function (string $method, string $url, array $options = []) use ($createAssetResponse, $expectedAuthHeader): ResponseInterface { + $expectedBody = http_build_query([ + 'name' => 'post.num_comments', + 'id' => 'post.num_comments', + 'type' => 'text', + 'default' => 'untranslated', + ]); + + $this->assertEquals('POST', $method); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals($expectedBody, $options['body']); + + return $createAssetResponse; + }, + 'getTags2' => function (string $method, string $url, array $options = []) use ($getTagsNotEmptyResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('GET', $method); + $this->assertEquals('https://localise.biz/api/tags.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return $getTagsNotEmptyResponse; + }, + 'createTag2' => function (string $method, string $url, array $options = []) use ($createTagResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/tags.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals(http_build_query(['name' => 'validators']), $options['body']); + + return $createTagResponse; + }, + 'tagAsset2' => function (string $method, string $url, array $options = []) use ($tagAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/tags/validators.json', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('post.num_comments', $options['body']); + + return $tagAssetResponse; + }, + + 'getLocales1' => function (string $method, string $url, array $options = []) use ($getLocalesResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('GET', $method); + $this->assertEquals('https://localise.biz/api/locales', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return $getLocalesResponse; + }, + + 'translateAsset1' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/translations/a/en', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('trans_en_a', $options['body']); + + return $translateAssetResponse; + }, + 'translateAsset2' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/translations/post.num_comments/en', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('{count, plural, one {# comment} other {# comments}}', $options['body']); + + return $translateAssetResponse; + }, + + 'getLocales2' => function (string $method, string $url, array $options = []) use ($getLocalesResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('GET', $method); + $this->assertEquals('https://localise.biz/api/locales', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + + return $getLocalesResponse; + }, + + 'createLocale1' => function (string $method, string $url, array $options = []) use ($createLocaleResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/locales', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('code=fr', $options['body']); + + return $createLocaleResponse; + }, + + 'translateAsset3' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/translations/a/fr', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('trans_fr_a', $options['body']); + + return $translateAssetResponse; + }, + 'translateAsset4' => function (string $method, string $url, array $options = []) use ($translateAssetResponse, $expectedAuthHeader): ResponseInterface { + $this->assertEquals('POST', $method); + $this->assertEquals('https://localise.biz/api/translations/post.num_comments/fr', $url); + $this->assertEquals($expectedAuthHeader, $options['normalized_headers']['authorization'][0]); + $this->assertEquals('{count, plural, one {# commentaire} other {# commentaires}}', $options['body']); + + return $translateAssetResponse; + }, + ]; + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue(new MessageCatalogue('en', [ + 'messages' => ['a' => 'trans_en_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# comment} other {# comments}}'], + ])); + $translatorBag->addCatalogue(new MessageCatalogue('fr', [ + 'messages' => ['a' => 'trans_fr_a'], + 'validators' => ['post.num_comments' => '{count, plural, one {# commentaire} other {# commentaires}}'], + ])); + + $provider = $this->createProvider((new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + $provider->write($translatorBag); + } + + /** + * @dataProvider getLocoResponsesForOneLocaleAndOneDomain + */ + public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once()) + ->method('getContent') + ->willReturn($responseContent); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $loader = $this->getLoader(); + $loader->expects($this->once()) + ->method('load') + ->willReturn($expectedTranslatorBag->getCatalogue($locale)); + + $locoProvider = $this->createProvider((new MockHttpClient($response))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), $loader, $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + $translatorBag = $locoProvider->read([$domain], [$locale]); + + $this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + /** + * @dataProvider getLocoResponsesForManyLocalesAndManyDomains + */ + public function testReadForManyLocalesAndManyDomains(array $locales, array $domains, array $responseContents, array $expectedTranslatorBags) + { + foreach ($locales as $locale) { + foreach ($domains as $domain) { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once()) + ->method('getContent') + ->willReturn($responseContents[$domain][$locale]); + $response->expects($this->exactly(2)) + ->method('getStatusCode') + ->willReturn(200); + + $locoProvider = new LocoProvider((new MockHttpClient($response))->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'); + $translatorBag = $locoProvider->read([$domain], [$locale]); + // We don't want to assert equality of metadata here, due to the ArrayLoader usage. + $translatorBag->getCatalogue($locale)->deleteMetadata('foo', ''); + + $this->assertEquals($expectedTranslatorBags[$domain]->getCatalogue($locale), $translatorBag->getCatalogue($locale)); + } + } + } + + public function toStringProvider(): iterable + { + yield [ + new LocoProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://localise.biz/api/', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'localise.biz/api/'), + 'loco://localise.biz/api/', + ]; + + yield [ + new LocoProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://example.com', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com'), + 'loco://example.com', + ]; + + yield [ + new LocoProvider($this->getClient()->withOptions([ + 'base_uri' => 'https://example.com:99', + 'headers' => [ + 'Authorization' => 'Loco API_KEY', + ], + ]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com:99'), + 'loco://example.com:99', + ]; + } + + public function getLocoResponsesForOneLocaleAndOneDomain(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBagEn = new TranslatorBag(); + $expectedTranslatorBagEn->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Hello', + 'index.greetings' => 'Welcome, {firstname}!', + ], 'en', 'messages')); + + yield ['en', 'messages', <<<'XLIFF' + + + +
+ +
+ + + index.hello + Hello + + + index.greetings + Welcome, {firstname}! + + +
+
+XLIFF + , + $expectedTranslatorBagEn, + ]; + + $expectedTranslatorBagFr = new TranslatorBag(); + $expectedTranslatorBagFr->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr', 'messages')); + + yield ['fr', 'messages', <<<'XLIFF' + + + +
+ +
+ + + index.hello + Bonjour + + + index.greetings + Bienvenue, {firstname} ! + + +
+
+XLIFF + , + $expectedTranslatorBagFr, + ]; + } + + public function getLocoResponsesForManyLocalesAndManyDomains(): \Generator + { + $arrayLoader = new ArrayLoader(); + + $expectedTranslatorBagMessages = new TranslatorBag(); + $expectedTranslatorBagMessages->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Hello', + 'index.greetings' => 'Welcome, {firstname}!', + ], 'en', 'messages')); + $expectedTranslatorBagMessages->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr', 'messages')); + + $expectedTranslatorBagValidators = new TranslatorBag(); + $expectedTranslatorBagValidators->addCatalogue($arrayLoader->load([ + 'firstname.error' => 'Firstname must contains only letters.', + 'lastname.error' => 'Lastname must contains only letters.', + ], 'en', 'validators')); + $expectedTranslatorBagValidators->addCatalogue($arrayLoader->load([ + 'firstname.error' => 'Le prénom ne peut contenir que des lettres.', + 'lastname.error' => 'Le nom de famille ne peut contenir que des lettres.', + ], 'fr', 'validators')); + + yield [ + ['en', 'fr'], + ['messages', 'validators'], + [ + 'messages' => [ + 'en' => <<<'XLIFF' + + + +
+ +
+ + + index.hello + Hello + + + index.greetings + Welcome, {firstname}! + + +
+
+XLIFF + , + 'fr' => <<<'XLIFF' + + + +
+ +
+ + + index.hello + Bonjour + + + index.greetings + Bienvenue, {firstname} ! + + +
+
+XLIFF + , + ], + 'validators' => [ + 'en' => <<<'XLIFF' + + + +
+ +
+ + + firstname.error + Firstname must contains only letters. + + + lastname.error + Lastname must contains only letters. + + +
+
+XLIFF + , + 'fr' => <<<'XLIFF' + + + +
+ +
+ + + firstname.error + Le prénom ne peut contenir que des lettres. + + + lastname.error + Le nom de famille ne peut contenir que des lettres. + + +
+
+XLIFF + , + ], + ], + [ + 'messages' => $expectedTranslatorBagMessages, + 'validators' => $expectedTranslatorBagValidators, + ], + ]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/composer.json b/src/Symfony/Component/Translation/Bridge/Loco/composer.json new file mode 100644 index 0000000000..7b57c2646f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/loco-translation", + "type": "symfony-bridge", + "description": "Symfony Loco Translation Bridge", + "keywords": ["loco", "translation", "provider"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathieu Santostefano", + "homepage": "https://github.com/welcomattic" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "symfony/http-client": "^5.3", + "symfony/translation": "^5.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Translation\\Bridge\\Loco\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Translation/Bridge/Loco/phpunit.xml.dist b/src/Symfony/Component/Translation/Bridge/Loco/phpunit.xml.dist new file mode 100644 index 0000000000..c58cb50d0e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Loco/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index b1eb2da062..1ff428b8e5 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Add `translation:pull` and `translation:push` commands to manage translations with third-party providers + 5.2.0 ----- diff --git a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php index 17c257fde4..9869fbb8bb 100644 --- a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php +++ b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php @@ -26,6 +26,10 @@ use Symfony\Component\Translation\MessageCatalogueInterface; */ abstract class AbstractOperation implements OperationInterface { + public const OBSOLETE_BATCH = 'obsolete'; + public const NEW_BATCH = 'new'; + public const ALL_BATCH = 'all'; + protected $source; protected $target; protected $result; @@ -94,11 +98,11 @@ abstract class AbstractOperation implements OperationInterface throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain)); } - if (!isset($this->messages[$domain]['all'])) { + if (!isset($this->messages[$domain][self::ALL_BATCH])) { $this->processDomain($domain); } - return $this->messages[$domain]['all']; + return $this->messages[$domain][self::ALL_BATCH]; } /** @@ -110,11 +114,11 @@ abstract class AbstractOperation implements OperationInterface throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain)); } - if (!isset($this->messages[$domain]['new'])) { + if (!isset($this->messages[$domain][self::NEW_BATCH])) { $this->processDomain($domain); } - return $this->messages[$domain]['new']; + return $this->messages[$domain][self::NEW_BATCH]; } /** @@ -126,11 +130,11 @@ abstract class AbstractOperation implements OperationInterface throw new InvalidArgumentException(sprintf('Invalid domain: "%s".', $domain)); } - if (!isset($this->messages[$domain]['obsolete'])) { + if (!isset($this->messages[$domain][self::OBSOLETE_BATCH])) { $this->processDomain($domain); } - return $this->messages[$domain]['obsolete']; + return $this->messages[$domain][self::OBSOLETE_BATCH]; } /** @@ -147,6 +151,37 @@ abstract class AbstractOperation implements OperationInterface return $this->result; } + /** + * @param self::*_BATCH $batch + */ + public function moveMessagesToIntlDomainsIfPossible(string $batch = self::ALL_BATCH): void + { + // If MessageFormatter class does not exists, intl domains are not supported. + if (!class_exists(\MessageFormatter::class)) { + return; + } + + foreach ($this->getDomains() as $domain) { + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + switch ($batch) { + case self::OBSOLETE_BATCH: $messages = $this->getObsoleteMessages($domain); break; + case self::NEW_BATCH: $messages = $this->getNewMessages($domain); break; + case self::ALL_BATCH: $messages = $this->getMessages($domain); break; + default: throw new \InvalidArgumentException(sprintf('$batch argument must be one of ["%s", "%s", "%s"].', self::ALL_BATCH, self::NEW_BATCH, self::OBSOLETE_BATCH)); + } + + if (!$messages || (!$this->source->all($intlDomain) && $this->source->all($domain))) { + continue; + } + + $result = $this->getResult(); + $allIntlMessages = $result->all($intlDomain); + $currentMessages = array_diff_key($messages, $result->all($domain)); + $result->replace($currentMessages, $domain); + $result->replace($allIntlMessages + $messages, $intlDomain); + } + } + /** * Performs operation on source and target catalogues for the given domain and * stores the results. diff --git a/src/Symfony/Component/Translation/Command/TranslationPullCommand.php b/src/Symfony/Component/Translation/Command/TranslationPullCommand.php new file mode 100644 index 0000000000..0ec02ca7b2 --- /dev/null +++ b/src/Symfony/Component/Translation/Command/TranslationPullCommand.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Catalogue\TargetOperation; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Provider\TranslationProviderCollection; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Writer\TranslationWriterInterface; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class TranslationPullCommand extends Command +{ + use TranslationTrait; + + protected static $defaultName = 'translation:pull'; + protected static $defaultDescription = 'Pull translations from a given provider.'; + + private $providerCollection; + private $writer; + private $reader; + private $defaultLocale; + private $transPaths; + private $enabledLocales; + + public function __construct(TranslationProviderCollection $providerCollection, TranslationWriterInterface $writer, TranslationReaderInterface $reader, string $defaultLocale, array $transPaths = [], array $enabledLocales = []) + { + $this->providerCollection = $providerCollection; + $this->writer = $writer; + $this->reader = $reader; + $this->defaultLocale = $defaultLocale; + $this->transPaths = $transPaths; + $this->enabledLocales = $enabledLocales; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $keys = $this->providerCollection->keys(); + $defaultProvider = 1 === \count($keys) ? $keys[0] : null; + + $this + ->setDefinition([ + new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to pull translations from.', $defaultProvider), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with provider ones (it will delete not synchronized messages).'), + new InputOption('intl-icu', null, InputOption::VALUE_NONE, 'Associated to --force option, it will write messages in "%domain%+intl-icu.%locale%.xlf" files.'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to pull.'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to pull.'), + new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format.', 'xlf12'), + ]) + ->setHelp(<<<'EOF' +The %command.name% command pulls translations from the given provider. Only +new translations are pulled, existing ones are not overwritten. + +You can overwrite existing translations (and remove the missing ones on local side) by using the --force flag: + + php %command.full_name% --force provider + +Full example: + + php %command.full_name% provider --force --domains=messages,validators --locales=en + +This command pulls all translations associated with the messages and validators domains for the en locale. +Local translations for the specified domains and locale are deleted if they're not present on the provider and overwritten if it's the case. +Local translations for others domains and locales are ignored. +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $provider = $this->providerCollection->get($input->getArgument('provider')); + $force = $input->getOption('force'); + $intlIcu = $input->getOption('intl-icu'); + $locales = $input->getOption('locales') ?: $this->enabledLocales; + $domains = $input->getOption('domains'); + $format = $input->getOption('format'); + $xliffVersion = '1.2'; + + if ($intlIcu && !$force) { + $io->note('--intl-icu option only has an effect when used with --force. Here, it will be ignored.'); + } + + switch ($format) { + case 'xlf20': $xliffVersion = '2.0'; + // no break + case 'xlf12': $format = 'xlf'; + } + + $writeOptions = [ + 'path' => end($this->transPaths), + 'xliff_version' => $xliffVersion, + ]; + + if (!$domains) { + $domains = $provider->getDomains(); + } + + $providerTranslations = $provider->read($domains, $locales); + + if ($force) { + foreach ($providerTranslations->getCatalogues() as $catalogue) { + $operation = new TargetOperation((new MessageCatalogue($catalogue->getLocale())), $catalogue); + if ($intlIcu) { + $operation->moveMessagesToIntlDomainsIfPossible(); + } + $this->writer->write($operation->getResult(), $format, $writeOptions); + } + + $io->success(sprintf('Local translations has been updated from "%s" (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); + + return 0; + } + + $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); + + // Append pulled translations to local ones. + $localTranslations->addBag($providerTranslations->diff($localTranslations)); + + foreach ($localTranslations->getCatalogues() as $catalogue) { + $this->writer->write($catalogue, $format, $writeOptions); + } + + $io->success(sprintf('New translations from "%s" has been written locally (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); + + return 0; + } +} diff --git a/src/Symfony/Component/Translation/Command/TranslationPushCommand.php b/src/Symfony/Component/Translation/Command/TranslationPushCommand.php new file mode 100644 index 0000000000..da265d6c8b --- /dev/null +++ b/src/Symfony/Component/Translation/Command/TranslationPushCommand.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Command; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Provider\TranslationProviderCollection; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\TranslatorBag; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class TranslationPushCommand extends Command +{ + use TranslationTrait; + + protected static $defaultName = 'translation:push'; + protected static $defaultDescription = 'Push translations to a given provider.'; + + private $providers; + private $reader; + private $transPaths; + private $enabledLocales; + + public function __construct(TranslationProviderCollection $providers, TranslationReaderInterface $reader, array $transPaths = [], array $enabledLocales = []) + { + $this->providers = $providers; + $this->reader = $reader; + $this->transPaths = $transPaths; + $this->enabledLocales = $enabledLocales; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $keys = $this->providers->keys(); + $defaultProvider = 1 === \count($keys) ? $keys[0] : null; + + $this + ->setDefinition([ + new InputArgument('provider', null !== $defaultProvider ? InputArgument::OPTIONAL : InputArgument::REQUIRED, 'The provider to push translations to.', $defaultProvider), + new InputOption('force', null, InputOption::VALUE_NONE, 'Override existing translations with local ones (it will delete not synchronized messages).'), + new InputOption('delete-missing', null, InputOption::VALUE_NONE, 'Delete translations available on provider but not locally.'), + new InputOption('domains', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the domains to push.'), + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to push.', $this->enabledLocales), + ]) + ->setHelp(<<<'EOF' +The %command.name% command pushes translations to the given provider. Only new +translations are pushed, existing ones are not overwritten. + +You can overwrite existing translations by using the --force flag: + + php %command.full_name% --force provider + +You can delete provider translations which are not present locally by using the --delete-missing flag: + + php %command.full_name% --delete-missing provider + +Full example: + + php %command.full_name% provider --force --delete-missing --domains=messages,validators --locales=en + +This command pushes all translations associated with the messages and validators domains for the en locale. +Provider translations for the specified domains and locale are deleted if they're not present locally and overwritten if it's the case. +Provider translations for others domains and locales are ignored. +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->enabledLocales) { + throw new InvalidArgumentException('You must define "framework.translator.enabled_locales" or "framework.translator.providers.%s.locales" config key in order to work with translation providers.'); + } + + $io = new SymfonyStyle($input, $output); + + $provider = $this->providers->get($input->getArgument('provider')); + $domains = $input->getOption('domains'); + $locales = $input->getOption('locales'); + $force = $input->getOption('force'); + $deleteMissing = $input->getOption('delete-missing'); + + $localTranslations = $this->readLocalTranslations($locales, $domains, $this->transPaths); + + if (!$domains) { + $domains = $this->getDomainsFromTranslatorBag($localTranslations); + } + + if (!$deleteMissing && $force) { + $provider->write($localTranslations); + + $io->success(sprintf('All local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); + + return 0; + } + + $providerTranslations = $provider->read($domains, $locales); + + if ($deleteMissing) { + $provider->delete($providerTranslations->diff($localTranslations)); + + $io->success(sprintf('Missing translations on "%s" has been deleted (for "%s" locale(s), and "%s" domain(s)).', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); + + // Read provider translations again, after missing translations deletion, + // to avoid push freshly deleted translations. + $providerTranslations = $provider->read($domains, $locales); + } + + $translationsToWrite = $localTranslations->diff($providerTranslations); + + if ($force) { + $translationsToWrite->addBag($localTranslations->intersect($providerTranslations)); + } + + $provider->write($translationsToWrite); + + $io->success(sprintf('%s local translations has been sent to "%s" (for "%s" locale(s), and "%s" domain(s)).', $force ? 'All' : 'New', parse_url($provider, \PHP_URL_SCHEME), implode(', ', $locales), implode(', ', $domains))); + + return 0; + } + + private function getDomainsFromTranslatorBag(TranslatorBag $translatorBag): array + { + $domains = []; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + $domains += $catalogue->getDomains(); + } + + return array_unique($domains); + } +} diff --git a/src/Symfony/Component/Translation/Command/TranslationTrait.php b/src/Symfony/Component/Translation/Command/TranslationTrait.php new file mode 100644 index 0000000000..6a2b1ba86d --- /dev/null +++ b/src/Symfony/Component/Translation/Command/TranslationTrait.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Command; + +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\Component\Translation\TranslatorBag; + +/** + * @internal + */ +trait TranslationTrait +{ + private function readLocalTranslations(array $locales, array $domains, array $transPaths): TranslatorBag + { + $bag = new TranslatorBag(); + + foreach ($locales as $locale) { + $catalogue = new MessageCatalogue($locale); + foreach ($transPaths as $path) { + $this->reader->read($path, $catalogue); + } + + if ($domains) { + foreach ($domains as $domain) { + $catalogue = $this->filterCatalogue($catalogue, $domain); + $bag->addCatalogue($catalogue); + } + } else { + $bag->addCatalogue($catalogue); + } + } + + return $bag; + } + + private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue + { + $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); + + // extract intl-icu messages only + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + if ($intlMessages = $catalogue->all($intlDomain)) { + $filteredCatalogue->add($intlMessages, $intlDomain); + } + + // extract all messages and subtract intl-icu messages + if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { + $filteredCatalogue->add($messages, $domain); + } + foreach ($catalogue->getResources() as $resource) { + $filteredCatalogue->addResource($resource); + } + + if ($metadata = $catalogue->getMetadata('', $intlDomain)) { + foreach ($metadata as $k => $v) { + $filteredCatalogue->setMetadata($k, $v, $intlDomain); + } + } + + if ($metadata = $catalogue->getMetadata('', $domain)) { + foreach ($metadata as $k => $v) { + $filteredCatalogue->setMetadata($k, $v, $domain); + } + } + + return $filteredCatalogue; + } +} diff --git a/src/Symfony/Component/Translation/DataCollectorTranslator.php b/src/Symfony/Component/Translation/DataCollectorTranslator.php index ed5008e1c7..c7d3597542 100644 --- a/src/Symfony/Component/Translation/DataCollectorTranslator.php +++ b/src/Symfony/Component/Translation/DataCollectorTranslator.php @@ -79,6 +79,14 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter return $this->translator->getCatalogue($locale); } + /** + * {@inheritdoc} + */ + public function getCatalogues(): array + { + return $this->translator->getCatalogues(); + } + /** * {@inheritdoc} * diff --git a/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php new file mode 100644 index 0000000000..192de3c657 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/IncompleteDsnException.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +class IncompleteDsnException extends InvalidArgumentException +{ + public function __construct(string $message, string $dsn = null, ?\Throwable $previous = null) + { + if ($dsn) { + $message = sprintf('Invalid "%s" provider DSN: ', $dsn).$message; + } + + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Symfony/Component/Translation/Exception/ProviderException.php b/src/Symfony/Component/Translation/Exception/ProviderException.php new file mode 100644 index 0000000000..659c6d7721 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/ProviderException.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Fabien Potencier + * + * @experimental in 5.3 + */ +class ProviderException extends RuntimeException implements ProviderExceptionInterface +{ + private $response; + private $debug; + + public function __construct(string $message, ResponseInterface $response, int $code = 0, \Exception $previous = null) + { + $this->response = $response; + $this->debug .= $response->getInfo('debug') ?? ''; + + parent::__construct($message, $code, $previous); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function getDebug(): string + { + return $this->debug; + } +} diff --git a/src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php b/src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php new file mode 100644 index 0000000000..8cf1c51c3d --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/ProviderExceptionInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +/** + * @author Fabien Potencier + * + * @experimental in 5.3 + */ +interface ProviderExceptionInterface extends ExceptionInterface +{ + /* + * Returns debug info coming from the Symfony\Contracts\HttpClient\ResponseInterface + */ + public function getDebug(): string; +} diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php new file mode 100644 index 0000000000..d53f3b0118 --- /dev/null +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Exception; + +use Symfony\Component\Translation\Bridge; +use Symfony\Component\Translation\Provider\Dsn; + +class UnsupportedSchemeException extends LogicException +{ + private const SCHEME_TO_PACKAGE_MAP = [ + 'loco' => [ + 'class' => Bridge\Loco\Provider\LocoProviderFactory::class, + 'package' => 'symfony/loco-translation', + ], + ]; + + public function __construct(Dsn $dsn, string $name = null, array $supported = []) + { + $provider = $dsn->getScheme(); + if (false !== $pos = strpos($provider, '+')) { + $provider = substr($provider, 0, $pos); + } + $package = self::SCHEME_TO_PACKAGE_MAP[$provider] ?? null; + if ($package && !class_exists($package['class'])) { + parent::__construct(sprintf('Unable to synchronize translations via "%s" as the providers is not installed; try running "composer require %s".', $provider, $package['package'])); + + return; + } + + $message = sprintf('The "%s" scheme is not supported', $dsn->getScheme()); + if ($name && $supported) { + $message .= sprintf('; supported schemes for translation providers "%s" are: "%s"', $name, implode('", "', $supported)); + } + + parent::__construct($message.'.'); + } +} diff --git a/src/Symfony/Component/Translation/Loader/XliffFileLoader.php b/src/Symfony/Component/Translation/Loader/XliffFileLoader.php index f573dfe4c7..2029fed928 100644 --- a/src/Symfony/Component/Translation/Loader/XliffFileLoader.php +++ b/src/Symfony/Component/Translation/Loader/XliffFileLoader.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Translation\Loader; use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Config\Util\Exception\InvalidXmlException; +use Symfony\Component\Config\Util\Exception\XmlParsingException; use Symfony\Component\Config\Util\XmlUtils; use Symfony\Component\Translation\Exception\InvalidResourceException; use Symfony\Component\Translation\Exception\NotFoundResourceException; @@ -35,36 +37,47 @@ class XliffFileLoader implements LoaderInterface throw new RuntimeException('Loading translations from the Xliff format requires the Symfony Config component.'); } - if (!stream_is_local($resource)) { - throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource)); + if (!$this->isXmlString($resource)) { + if (!stream_is_local($resource)) { + throw new InvalidResourceException(sprintf('This is not a local file "%s".', $resource)); + } + + if (!file_exists($resource)) { + throw new NotFoundResourceException(sprintf('File "%s" not found.', $resource)); + } + + if (!is_file($resource)) { + throw new InvalidResourceException(sprintf('This is neither a file nor an XLIFF string "%s".', $resource)); + } } - if (!file_exists($resource)) { - throw new NotFoundResourceException(sprintf('File "%s" not found.', $resource)); + try { + if ($this->isXmlString($resource)) { + $dom = XmlUtils::parse($resource); + } else { + $dom = XmlUtils::loadFile($resource); + } + } catch (\InvalidArgumentException | XmlParsingException | InvalidXmlException $e) { + throw new InvalidResourceException(sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e); + } + + if ($errors = XliffUtils::validateSchema($dom)) { + throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors)); } $catalogue = new MessageCatalogue($locale); - $this->extract($resource, $catalogue, $domain); + $this->extract($dom, $catalogue, $domain); - if (class_exists(FileResource::class)) { + if (is_file($resource) && class_exists(FileResource::class)) { $catalogue->addResource(new FileResource($resource)); } return $catalogue; } - private function extract($resource, MessageCatalogue $catalogue, string $domain) + private function extract($dom, MessageCatalogue $catalogue, string $domain) { - try { - $dom = XmlUtils::loadFile($resource); - } catch (\InvalidArgumentException $e) { - throw new InvalidResourceException(sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e); - } - $xliffVersion = XliffUtils::getVersionNumber($dom); - if ($errors = XliffUtils::validateSchema($dom)) { - throw new InvalidResourceException(sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors)); - } if ('1.2' === $xliffVersion) { $this->extractXliff1($dom, $catalogue, $domain); @@ -211,4 +224,9 @@ class XliffFileLoader implements LoaderInterface return $notes; } + + private function isXmlString(string $resource): bool + { + return 0 === strpos($resource, 'translator->getCatalogue($locale); } + /** + * {@inheritdoc} + */ + public function getCatalogues(): array + { + return $this->translator->getCatalogues(); + } + /** * Gets the fallback locales. * diff --git a/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php new file mode 100644 index 0000000000..17442fde87 --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/AbstractProviderFactory.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\IncompleteDsnException; + +abstract class AbstractProviderFactory implements ProviderFactoryInterface +{ + public function supports(Dsn $dsn): bool + { + return \in_array($dsn->getScheme(), $this->getSupportedSchemes(), true); + } + + /** + * @return string[] + */ + abstract protected function getSupportedSchemes(): array; + + protected function getUser(Dsn $dsn): string + { + if (null === $user = $dsn->getUser()) { + throw new IncompleteDsnException('User is not set.', $dsn->getOriginalDsn()); + } + + return $user; + } + + protected function getPassword(Dsn $dsn): string + { + if (null === $password = $dsn->getPassword()) { + throw new IncompleteDsnException('Password is not set.', $dsn->getOriginalDsn()); + } + + return $password; + } +} diff --git a/src/Symfony/Component/Translation/Provider/Dsn.php b/src/Symfony/Component/Translation/Provider/Dsn.php new file mode 100644 index 0000000000..67f805c9d8 --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/Dsn.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class Dsn +{ + private $scheme; + private $host; + private $user; + private $password; + private $port; + private $options; + private $path; + private $dsn; + + public function __construct(string $scheme, string $host, ?string $user = null, ?string $password = null, ?int $port = null, array $options = [], ?string $path = null) + { + $this->scheme = $scheme; + $this->host = $host; + $this->user = $user; + $this->password = $password; + $this->port = $port; + $this->options = $options; + $this->path = $path; + } + + public static function fromString(string $dsn): self + { + if (false === $parsedDsn = parse_url($dsn)) { + throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN is invalid.', $dsn)); + } + + if (!isset($parsedDsn['scheme'])) { + throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN must contain a scheme.', $dsn)); + } + + if (!isset($parsedDsn['host'])) { + throw new InvalidArgumentException(sprintf('The "%s" translation provider DSN must contain a host (use "default" by default).', $dsn)); + } + + $user = isset($parsedDsn['user']) ? urldecode($parsedDsn['user']) : null; + $password = isset($parsedDsn['pass']) ? urldecode($parsedDsn['pass']) : null; + $port = $parsedDsn['port'] ?? null; + $path = $parsedDsn['path'] ?? null; + parse_str($parsedDsn['query'] ?? '', $query); + + $dsnObject = new self($parsedDsn['scheme'], $parsedDsn['host'], $user, $password, $port, $query, $path); + $dsnObject->dsn = $dsn; + + return $dsnObject; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getHost(): string + { + return $this->host; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getPort(int $default = null): ?int + { + return $this->port ?? $default; + } + + public function getOption(string $key, $default = null) + { + return $this->options[$key] ?? $default; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function getOriginalDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Symfony/Component/Translation/Provider/FilteringProvider.php b/src/Symfony/Component/Translation/Provider/FilteringProvider.php new file mode 100644 index 0000000000..0307cdacf0 --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/FilteringProvider.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; + +/** + * Filters domains and locales between the Translator config values and those specific to each provider. + * + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +class FilteringProvider implements ProviderInterface +{ + private $provider; + private $locales; + private $domains; + + public function __construct(ProviderInterface $provider, array $locales, array $domains = []) + { + $this->provider = $provider; + $this->locales = $locales; + $this->domains = $domains; + } + + public function __toString(): string + { + return (string) $this->provider; + } + + /** + * {@inheritdoc} + */ + public function write(TranslatorBagInterface $translatorBag): void + { + $this->provider->write($translatorBag); + } + + public function read(array $domains, array $locales): TranslatorBag + { + $domains = !$this->domains ? $domains : array_intersect($this->domains, $domains); + $locales = array_intersect($this->locales, $locales); + + return $this->provider->read($domains, $locales); + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $this->provider->delete($translatorBag); + } + + public function getDomains(): array + { + return $this->domains; + } +} diff --git a/src/Symfony/Component/Translation/Provider/NullProvider.php b/src/Symfony/Component/Translation/Provider/NullProvider.php new file mode 100644 index 0000000000..f180a5030c --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/NullProvider.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +class NullProvider implements ProviderInterface +{ + public function __toString(): string + { + return NullProviderFactory::SCHEME.'://default'; + } + + public function write(TranslatorBagInterface $translatorBag, bool $override = false): void + { + } + + public function read(array $domains, array $locales): TranslatorBag + { + return new TranslatorBag(); + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + } +} diff --git a/src/Symfony/Component/Translation/Provider/NullProviderFactory.php b/src/Symfony/Component/Translation/Provider/NullProviderFactory.php new file mode 100644 index 0000000000..81a02df6b2 --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/NullProviderFactory.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class NullProviderFactory extends AbstractProviderFactory +{ + const SCHEME = 'null'; + + public function create(Dsn $dsn): ProviderInterface + { + if (self::SCHEME === $dsn->getScheme()) { + return new NullProvider(); + } + + throw new UnsupportedSchemeException($dsn, self::SCHEME, $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return [self::SCHEME]; + } +} diff --git a/src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php b/src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php new file mode 100644 index 0000000000..3fd4494b4a --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/ProviderFactoryInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; + +interface ProviderFactoryInterface +{ + /** + * @throws UnsupportedSchemeException + * @throws IncompleteDsnException + */ + public function create(Dsn $dsn): ProviderInterface; + + public function supports(Dsn $dsn): bool; +} diff --git a/src/Symfony/Component/Translation/Provider/ProviderInterface.php b/src/Symfony/Component/Translation/Provider/ProviderInterface.php new file mode 100644 index 0000000000..a32193f29c --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/ProviderInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; + +interface ProviderInterface +{ + public function __toString(): string; + + /** + * Translations available in the TranslatorBag only must be created. + * Translations available in both the TranslatorBag and on the provider + * must be overwritten. + * Translations available on the provider only must be kept. + */ + public function write(TranslatorBagInterface $translatorBag): void; + + public function read(array $domains, array $locales): TranslatorBag; + + public function delete(TranslatorBagInterface $translatorBag): void; +} diff --git a/src/Symfony/Component/Translation/Provider/TranslationProviderCollection.php b/src/Symfony/Component/Translation/Provider/TranslationProviderCollection.php new file mode 100644 index 0000000000..9963cb9f8c --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/TranslationProviderCollection.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\InvalidArgumentException; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class TranslationProviderCollection +{ + private $providers; + + /** + * @param array $providers + */ + public function __construct(iterable $providers) + { + $this->providers = []; + foreach ($providers as $name => $provider) { + $this->providers[$name] = $provider; + } + } + + public function __toString(): string + { + return '['.implode(',', array_keys($this->providers)).']'; + } + + public function has(string $name): bool + { + return isset($this->providers[$name]); + } + + public function get(string $name): ProviderInterface + { + if (!$this->has($name)) { + throw new InvalidArgumentException(sprintf('Provider "%s" not found. Available: "%s".', $name, (string) $this)); + } + + return $this->providers[$name]; + } + + public function keys(): array + { + return array_keys($this->providers); + } +} diff --git a/src/Symfony/Component/Translation/Provider/TranslationProviderCollectionFactory.php b/src/Symfony/Component/Translation/Provider/TranslationProviderCollectionFactory.php new file mode 100644 index 0000000000..5c0b1eab7c --- /dev/null +++ b/src/Symfony/Component/Translation/Provider/TranslationProviderCollectionFactory.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Provider; + +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +class TranslationProviderCollectionFactory +{ + private $factories; + private $enabledLocales; + + /** + * @param ProviderFactoryInterface[] $factories + */ + public function __construct(iterable $factories, array $enabledLocales) + { + $this->factories = $factories; + $this->enabledLocales = $enabledLocales; + } + + public function fromConfig(array $config): TranslationProviderCollection + { + $providers = []; + foreach ($config as $name => $currentConfig) { + $providers[$name] = $this->fromDsnObject( + Dsn::fromString($currentConfig['dsn']), + !$currentConfig['locales'] ? $this->enabledLocales : $currentConfig['locales'], + !$currentConfig['domains'] ? [] : $currentConfig['domains'] + ); + } + + return new TranslationProviderCollection($providers); + } + + public function fromDsnObject(Dsn $dsn, array $locales, array $domains = []): ProviderInterface + { + foreach ($this->factories as $factory) { + if ($factory->supports($dsn)) { + return new FilteringProvider($factory->create($dsn), $locales, $domains); + } + } + + throw new UnsupportedSchemeException($dsn); + } +} diff --git a/src/Symfony/Component/Translation/Tests/Command/TranslationProviderTestCase.php b/src/Symfony/Component/Translation/Tests/Command/TranslationProviderTestCase.php new file mode 100644 index 0000000000..c490fa096c --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Command/TranslationProviderTestCase.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\CommonResponseTrait; +use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProvider; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Provider\FilteringProvider; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\Provider\TranslationProviderCollection; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Mathieu Santostefano + */ +abstract class TranslationProviderTestCase extends TestCase +{ + protected $fs; + protected $translationAppDir; + protected $files; + protected $defaultLocale; + + protected function setUp(): void + { + parent::setUp(); + $this->defaultLocale = \Locale::getDefault(); + \Locale::setDefault('en'); + $this->fs = new Filesystem(); + $this->translationAppDir = sys_get_temp_dir().'/'.uniqid('sf_translation', true); + $this->fs->mkdir($this->translationAppDir.'/translations'); + } + + protected function tearDown(): void + { + \Locale::setDefault($this->defaultLocale); + $this->fs->remove($this->translationAppDir); + parent::tearDown(); + } + + protected function getProviderCollection(ProviderInterface $provider, array $locales = ['en'], array $domains = ['messages']): TranslationProviderCollection + { + return new TranslationProviderCollection([ + 'loco' => new FilteringProvider($provider, $locales, $domains), + ]); + } + + protected function createFile(array $messages = ['note' => 'NOTE'], $targetLanguage = 'en', $fileNamePattern = 'messages.%locale%.xlf', string $xlfVersion = 'xlf12'): string + { + if ($xlfVersion === 'xlf12') { + $transUnits = ''; + foreach ($messages as $key => $value) { + $transUnits .= << + $key + $value + +XLIFF; + } + $xliffContent = << + + + + $transUnits + + + +XLIFF; + } else { + $units = ''; + foreach ($messages as $key => $value) { + $units .= << + + $key + $value + + +XLIFF; + } + $xliffContent = << + + + $units + + +XLIFF; + } + + $filename = sprintf('%s/%s', $this->translationAppDir.'/translations', str_replace('%locale%', $targetLanguage, $fileNamePattern)); + file_put_contents($filename, $xliffContent); + + $this->files[] = $filename; + + return $filename; + } +} diff --git a/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php b/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php new file mode 100644 index 0000000000..0d5793e37d --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Translation\Command\TranslationPullCommand; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Loader\ArrayLoader; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\Reader\TranslationReader; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\Writer\TranslationWriter; + +/** + * @author Mathieu Santostefano + */ +class TranslationPullCommandTest extends TranslationProviderTestCase +{ + public function testPullNewXlf12Messages() + { + $arrayLoader = new ArrayLoader(); + $filenameEn = $this->createFile(); + $filenameFr = $this->createFile(['note' => 'NOTE'], 'fr'); + $locales = ['en', 'fr']; + $domains = ['messages']; + + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'new.foo' => 'newFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'new.foo' => 'nouveauFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages']]); + + $this->assertStringContainsString('[OK] New translations from "null" has been written locally (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + new.foo + newFoo + + + note + NOTE + + +
+
+XLIFF + , file_get_contents($filenameEn)); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + new.foo + nouveauFoo + + + note + NOTE + + +
+
+XLIFF + , file_get_contents($filenameFr)); + } + + public function testPullNewXlf20Messages() + { + $arrayLoader = new ArrayLoader(); + $filenameEn = $this->createFile(['note' => 'NOTE'], 'en', 'messages.%locale%.xlf', 'xlf20'); + $filenameFr = $this->createFile(['note' => 'NOTE'], 'fr', 'messages.%locale%.xlf', 'xlf20'); + $locales = ['en', 'fr']; + $domains = ['messages']; + + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'new.foo' => 'newFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'new.foo' => 'nouveauFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--format' => 'xlf20']); + + $this->assertStringContainsString('[OK] New translations from "null" has been written locally (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertXmlStringEqualsXmlString(<< + + + + + new.foo + newFoo + + + + + note + NOTE + + + + +XLIFF + , file_get_contents($filenameEn)); + $this->assertXmlStringEqualsXmlString(<< + + + + + new.foo + nouveauFoo + + + + + note + NOTE + + + + +XLIFF + , file_get_contents($filenameFr)); + } + + public function testPullForceMessages() + { + $arrayLoader = new ArrayLoader(); + $filenameEn = $this->createFile(); + $filenameFr = $this->createFile(['note' => 'NOTE'], 'fr'); + $locales = ['en', 'fr']; + $domains = ['messages']; + + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'UPDATED NOTE', + 'new.foo' => 'newFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE MISE À JOUR', + 'new.foo' => 'nouveauFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--force' => true]); + + $this->assertStringContainsString('[OK] Local translations has been updated from "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + note + UPDATED NOTE + + + new.foo + newFoo + + +
+
+XLIFF + , file_get_contents($filenameEn)); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + note + NOTE MISE À JOUR + + + new.foo + nouveauFoo + + +
+
+XLIFF + , file_get_contents($filenameFr)); + } + + /** + * @requires extension intl + */ + public function testPullForceIntlIcuMessages() + { + $arrayLoader = new ArrayLoader(); + $filenameEn = $this->createFile(['note' => 'NOTE'], 'en', 'messages+intl-icu.%locale%.xlf'); + $filenameFr = $this->createFile(['note' => 'NOTE'], 'fr', 'messages+intl-icu.%locale%.xlf'); + + $locales = ['en', 'fr']; + $domains = ['messages']; + + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'UPDATED NOTE', + 'new.foo' => 'newFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE MISE À JOUR', + 'new.foo' => 'nouveauFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--force' => true, '--intl-icu' => true]); + + $this->assertStringContainsString('[OK] Local translations has been updated from "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + note + UPDATED NOTE + + + new.foo + newFoo + + +
+
+XLIFF + , file_get_contents($filenameEn)); + $this->assertXmlStringEqualsXmlString(<< + + +
+ +
+ + + note + NOTE MISE À JOUR + + + new.foo + nouveauFoo + + +
+
+XLIFF + , file_get_contents($filenameFr)); + } + + private function createCommandTester(ProviderInterface $provider, array $locales = ['en'], array $domains = ['messages']): CommandTester + { + $writer = new TranslationWriter(); + $writer->addDumper('xlf', new XliffFileDumper()); + + $reader = new TranslationReader(); + $reader->addLoader('xlf', new XliffFileLoader()); + + $command = new TranslationPullCommand( + $this->getProviderCollection($provider, $locales, $domains), + $writer, + $reader, + 'en', + [$this->translationAppDir.'/translations'] + ); + $application = new Application(); + $application->add($command); + + return new CommandTester($application->find('translation:pull')); + } +} diff --git a/src/Symfony/Component/Translation/Tests/Command/TranslationPushCommandTest.php b/src/Symfony/Component/Translation/Tests/Command/TranslationPushCommandTest.php new file mode 100644 index 0000000000..bcb8514598 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Command/TranslationPushCommandTest.php @@ -0,0 +1,253 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Translation\Command\TranslationPushCommand; +use Symfony\Component\Translation\Loader\ArrayLoader; +use Symfony\Component\Translation\Loader\XliffFileLoader; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\Reader\TranslationReader; +use Symfony\Component\Translation\Tests\Command\TranslationProviderTestCase; +use Symfony\Component\Translation\TranslatorBag; + +/** + * @author Mathieu Santostefano + */ +class TranslationPushCommandTest extends TranslationProviderTestCase +{ + public function testPushNewMessages() + { + $arrayLoader = new ArrayLoader(); + $xliffLoader = new XliffFileLoader(); + $locales = ['en', 'fr']; + $domains = ['messages']; + + // Simulate existing messages on Provider + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + // Create local files, with a new message + $filenameEn = $this->createFile([ + 'note' => 'NOTE', + 'new.foo' => 'newFoo', + ]); + $filenameFr = $this->createFile([ + 'note' => 'NOTE', + 'new.foo' => 'nouveauFoo', + ], 'fr'); + $localTranslatorBag = new TranslatorBag(); + $localTranslatorBag->addCatalogue($xliffLoader->load($filenameEn, 'en')); + $localTranslatorBag->addCatalogue($xliffLoader->load($filenameFr, 'fr')); + + $provider->expects($this->once()) + ->method('write') + ->with($localTranslatorBag->diff($providerReadTranslatorBag)); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages']]); + + $this->assertStringContainsString('[OK] New local translations has been sent to "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + } + + public function testPushForceMessages() + { + $xliffLoader = new XliffFileLoader(); + $filenameEn = $this->createFile([ + 'note' => 'NOTE UPDATED', + 'new.foo' => 'newFoo', + ]); + $filenameFr = $this->createFile([ + 'note' => 'NOTE MISE À JOUR', + 'new.foo' => 'nouveauFoo', + ], 'fr'); + $locales = ['en', 'fr']; + $domains = ['messages']; + + $provider = $this->createMock(ProviderInterface::class); + + $localTranslatorBag = new TranslatorBag(); + $localTranslatorBag->addCatalogue($xliffLoader->load($filenameEn, 'en')); + $localTranslatorBag->addCatalogue($xliffLoader->load($filenameFr, 'fr')); + + $provider->expects($this->once()) + ->method('write') + ->with($localTranslatorBag); + + $provider->expects($this->once()) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--force' => true]); + + $this->assertStringContainsString('[OK] All local translations has been sent to "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + } + + public function testDeleteMissingMessages() + { + $xliffLoader = new XliffFileLoader(); + $arrayLoader = new ArrayLoader(); + $locales = ['en', 'fr']; + $domains = ['messages']; + + // Simulate existing messages on Provider. + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'obsolete.foo' => 'obsoleteFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'obsolete.foo' => 'obsolèteFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->any()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + // Create local bag, with a missing message. + $localTranslatorBag = new TranslatorBag(); + $localTranslatorBag->addCatalogue($xliffLoader->load($this->createFile(), 'en')); + $localTranslatorBag->addCatalogue($xliffLoader->load($this->createFile(['note' => 'NOTE'], 'fr'), 'fr')); + + $missingTranslatorBag = $providerReadTranslatorBag->diff($localTranslatorBag); + + $provider->expects($this->once()) + ->method('delete') + ->with($missingTranslatorBag); + + // Read provider translations again, after missing translations deletion, + // to avoid push freshly deleted translations. + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'fr')); + + $provider->expects($this->any()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $provider->expects($this->once()) + ->method('write') + ->with($localTranslatorBag->diff($providerReadTranslatorBag)); + + $provider->expects($this->exactly(2)) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--delete-missing' => true]); + + $this->assertStringContainsString('[OK] Missing translations on "null" has been deleted (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertStringContainsString('[OK] New local translations has been sent to "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + } + + public function testPushForceAndDeleteMissingMessages() + { + $xliffLoader = new XliffFileLoader(); + $arrayLoader = new ArrayLoader(); + $locales = ['en', 'fr']; + $domains = ['messages']; + + // Simulate existing messages on Provider. + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'obsolete.foo' => 'obsoleteFoo', + ], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load([ + 'note' => 'NOTE', + 'obsolete.foo' => 'obsolèteFoo', + ], 'fr')); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->any()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + // Create local bag, with a missing message, an updated one and a new one. + $localTranslatorBag = new TranslatorBag(); + $localTranslatorBag->addCatalogue($xliffLoader->load($this->createFile(['note' => 'NOTE UPDATED', 'note2' => 'NOTE 2']), 'en')); + $localTranslatorBag->addCatalogue($xliffLoader->load($this->createFile(['note' => 'NOTE MISE À JOUR', 'note2' => 'NOTE 2'], 'fr'), 'fr')); + + $missingTranslatorBag = $providerReadTranslatorBag->diff($localTranslatorBag); + + $provider->expects($this->once()) + ->method('delete') + ->with($missingTranslatorBag); + + // Read provider translations again, after missing translations deletion, + // to avoid push freshly deleted translations. + $providerReadTranslatorBag = new TranslatorBag(); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'en')); + $providerReadTranslatorBag->addCatalogue($arrayLoader->load(['note' => 'NOTE'], 'fr')); + + $provider->expects($this->any()) + ->method('read') + ->with($domains, $locales) + ->willReturn($providerReadTranslatorBag); + + $translationBagToWrite = $localTranslatorBag->diff($providerReadTranslatorBag); + $translationBagToWrite->addBag($localTranslatorBag->intersect($providerReadTranslatorBag)); + + $provider->expects($this->once()) + ->method('write') + ->with($translationBagToWrite); + + $provider->expects($this->exactly(2)) + ->method('__toString') + ->willReturn('null://default'); + + $tester = $this->createCommandTester($provider, $locales, $domains); + + $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages'], '--force' => true, '--delete-missing' => true]); + + $this->assertStringContainsString('[OK] Missing translations on "null" has been deleted (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + $this->assertStringContainsString('[OK] All local translations has been sent to "null" (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay())); + } + + private function createCommandTester(ProviderInterface $provider, array $locales = ['en'], array $domains = ['messages']): CommandTester + { + $reader = new TranslationReader(); + $reader->addLoader('xlf', new XliffFileLoader()); + + $command = new TranslationPushCommand( + $this->getProviderCollection($provider, $locales, $domains), + $reader, + [$this->translationAppDir.'/translations'], + $locales + ); + $application = new Application(); + $application->add($command); + + return new CommandTester($application->find('translation:push')); + } +} diff --git a/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslationPathsPassTest.php b/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslationPathsPassTest.php index 42ab398dff..ec803eb5a1 100644 --- a/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslationPathsPassTest.php +++ b/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslationPathsPassTest.php @@ -72,7 +72,7 @@ class TranslationPathsPassTest extends TestCase ->setArguments([new Reference('.service_locator.bar')]) ; - $pass = new TranslatorPathsPass('translator', 'console.command.translation_debug', 'console.command.translation_update', 'argument_resolver.service'); + $pass = new TranslatorPathsPass(); $pass->process($container); $expectedPaths = [ diff --git a/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php b/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php index 9836ff1cb7..6cfa16de04 100644 --- a/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php +++ b/src/Symfony/Component/Translation/Tests/Loader/XliffFileLoaderTest.php @@ -19,7 +19,7 @@ use Symfony\Component\Translation\Loader\XliffFileLoader; class XliffFileLoaderTest extends TestCase { - public function testLoad() + public function testLoadFile() { $loader = new XliffFileLoader(); $resource = __DIR__.'/../fixtures/resources.xlf'; @@ -31,6 +31,42 @@ class XliffFileLoaderTest extends TestCase $this->assertContainsOnly('string', $catalogue->all('domain1')); } + public function testLoadRawXliff() + { + $loader = new XliffFileLoader(); + $resource = << + + + + + foo + bar + + + extra + + + key + + + + test + with + note + + + + +XLIFF; + + $catalogue = $loader->load($resource, 'en', 'domain1'); + + $this->assertEquals('en', $catalogue->getLocale()); + $this->assertSame([], libxml_get_errors()); + $this->assertContainsOnly('string', $catalogue->all('domain1')); + } + public function testLoadWithInternalErrorsEnabled() { $internalErrors = libxml_use_internal_errors(true); diff --git a/src/Symfony/Component/Translation/Tests/Provider/DsnTest.php b/src/Symfony/Component/Translation/Tests/Provider/DsnTest.php new file mode 100644 index 0000000000..e6cf8a388d --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Provider/DsnTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Exception\InvalidArgumentException; +use Symfony\Component\Translation\Provider\Dsn; + +class DsnTest extends TestCase +{ + /** + * @dataProvider fromStringProvider + */ + public function testFromString(string $string, Dsn $expectedDsn): void + { + $actualDsn = Dsn::fromString($string); + + $this->assertSame($expectedDsn->getScheme(), $actualDsn->getScheme()); + $this->assertSame($expectedDsn->getHost(), $actualDsn->getHost()); + $this->assertSame($expectedDsn->getPort(), $actualDsn->getPort()); + $this->assertSame($expectedDsn->getUser(), $actualDsn->getUser()); + $this->assertSame($expectedDsn->getPassword(), $actualDsn->getPassword()); + $this->assertSame($expectedDsn->getPath(), $actualDsn->getPath()); + $this->assertSame($expectedDsn->getOption('from'), $actualDsn->getOption('from')); + + $this->assertSame($string, $actualDsn->getOriginalDsn()); + } + + public function fromStringProvider(): iterable + { + yield 'simple dsn' => [ + 'scheme://localhost', + new Dsn('scheme', 'localhost', null, null, null, [], null), + ]; + + yield 'dsn with user and pass' => [ + 'scheme://u$er:pa$s@localhost', + new Dsn('scheme', 'localhost', 'u$er', 'pa$s', null, [], null), + ]; + + yield 'dsn with user and pass and custom port' => [ + 'scheme://u$er:pa$s@localhost:8000', + new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', [], null), + ]; + + yield 'dsn with user and pass, custom port and custom path' => [ + 'scheme://u$er:pa$s@localhost:8000/channel', + new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', [], '/channel'), + ]; + + yield 'dsn with user and pass, custom port, custom path and custom options' => [ + 'scheme://u$er:pa$s@localhost:8000/channel?from=FROM', + new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', ['from' => 'FROM'], '/channel'), + ]; + + yield 'dsn with user and pass that contains an urlencoded character' => [ + 'scheme://u$er:p%2Fa$s@localhost', + new Dsn('scheme', 'localhost', 'u$er', 'p/a$s'), + ]; + } + + /** + * @dataProvider invalidDsnProvider + */ + public function testInvalidDsn(string $dsn, string $exceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exceptionMessage); + Dsn::fromString($dsn); + } + + public function invalidDsnProvider(): iterable + { + yield [ + 'some://', + 'The "some://" translation provider DSN is invalid.', + ]; + + yield [ + '//loco', + 'The "//loco" translation provider DSN must contain a scheme.', + ]; + + yield [ + 'file:///some/path', + 'The "file:///some/path" translation provider DSN must contain a host (use "default" by default).', + ]; + } + + public function testGetOption(): void + { + $options = ['with_value' => 'some value', 'nullable' => null]; + $dsn = new Dsn('scheme', 'localhost', 'u$er', 'pa$s', '8000', $options, '/channel'); + + $this->assertSame('some value', $dsn->getOption('with_value')); + $this->assertSame('default', $dsn->getOption('nullable', 'default')); + $this->assertSame('default', $dsn->getOption('not_existent_property', 'default')); + } +} diff --git a/src/Symfony/Component/Translation/Tests/Provider/NullProviderFactoryTest.php b/src/Symfony/Component/Translation/Tests/Provider/NullProviderFactoryTest.php new file mode 100644 index 0000000000..6f35ebad66 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Provider/NullProviderFactoryTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\NullProvider; +use Symfony\Component\Translation\Provider\NullProviderFactory; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +class NullProviderFactoryTest extends TestCase +{ + public function testCreateThrowsUnsupportedSchemeException() + { + $this->expectException(UnsupportedSchemeException::class); + + (new NullProviderFactory())->create(new Dsn('foo', '')); + } + + public function testCreate() + { + $this->assertInstanceOf(NullProvider::class, (new NullProviderFactory())->create(new Dsn('null', ''))); + } +} diff --git a/src/Symfony/Component/Translation/Tests/ProviderFactoryTestCase.php b/src/Symfony/Component/Translation/Tests/ProviderFactoryTestCase.php new file mode 100644 index 0000000000..8bb2250734 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/ProviderFactoryTestCase.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderFactoryInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A test case to ease testing a translation provider factory. + * + * @author Mathieu Santostefano + */ +abstract class ProviderFactoryTestCase extends TestCase +{ + protected $client; + protected $logger; + protected $defaultLocale; + protected $loader; + protected $xliffFileDumper; + + abstract public function createFactory(): ProviderFactoryInterface; + + /** + * @return iterable + */ + abstract public function supportsProvider(): iterable; + + /** + * @return iterable + */ + abstract public function createProvider(): iterable; + + /** + * @return iterable + */ + public function unsupportedSchemeProvider(): iterable + { + return []; + } + + /** + * @return iterable + */ + public function incompleteDsnProvider(): iterable + { + return []; + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(bool $expected, string $dsn) + { + $factory = $this->createFactory(); + + $this->assertSame($expected, $factory->supports(Dsn::fromString($dsn))); + } + + /** + * @dataProvider unsupportedSchemeProvider + */ + public function testUnsupportedSchemeException(string $dsn, string $message = null) + { + $factory = $this->createFactory(); + + $dsn = Dsn::fromString($dsn); + + $this->expectException(UnsupportedSchemeException::class); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + + $factory->create($dsn); + } + + /** + * @dataProvider createProvider + */ + public function testCreate(string $expected, string $dsn) + { + $factory = $this->createFactory(); + $provider = $factory->create(Dsn::fromString($dsn)); + + $this->assertSame($expected, (string) $provider); + } + + /** + * @dataProvider incompleteDsnProvider + */ + public function testIncompleteDsnException(string $dsn, string $message = null) + { + $factory = $this->createFactory(); + + $dsn = Dsn::fromString($dsn); + + $this->expectException(IncompleteDsnException::class); + if (null !== $message) { + $this->expectExceptionMessage($message); + } + + $factory->create($dsn); + } + + protected function getClient(): HttpClientInterface + { + return $this->client ?? $this->client = new MockHttpClient(); + } + + protected function getLogger(): LoggerInterface + { + return $this->logger ?? $this->logger = $this->createMock(LoggerInterface::class); + } + + protected function getDefaultLocale(): string + { + return $this->defaultLocale ?? $this->defaultLocale = 'en'; + } + + protected function getLoader(): LoaderInterface + { + return $this->loader ?? $this->loader = $this->createMock(LoaderInterface::class); + } + + protected function getXliffFileDumper(): XliffFileDumper + { + return $this->xliffFileDumper ?? $this->xliffFileDumper = $this->createMock(XliffFileDumper::class); + } +} diff --git a/src/Symfony/Component/Translation/Tests/ProviderTestCase.php b/src/Symfony/Component/Translation/Tests/ProviderTestCase.php new file mode 100644 index 0000000000..fee89a2d5f --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/ProviderTestCase.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProvider; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * A test case to ease testing a translation provider. + * + * @author Mathieu Santostefano + */ +abstract class ProviderTestCase extends TestCase +{ + protected $client; + protected $logger; + protected $defaultLocale; + protected $loader; + protected $xliffFileDumper; + + abstract public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface; + + /** + * @return iterable + */ + abstract public function toStringProvider(): iterable; + + /** + * @dataProvider toStringProvider + */ + public function testToString(LocoProvider $provider, string $expected) + { + $this->assertSame($expected, (string) $provider); + } + + protected function getClient(): MockHttpClient + { + return $this->client ?? $this->client = new MockHttpClient(); + } + + protected function getLoader(): LoaderInterface + { + return $this->loader ?? $this->loader = $this->createMock(LoaderInterface::class); + } + + protected function getLogger(): LoggerInterface + { + return $this->logger ?? $this->logger = $this->createMock(LoggerInterface::class); + } + + protected function getDefaultLocale(): string + { + return $this->defaultLocale ?? $this->defaultLocale = 'en'; + } + + protected function getXliffFileDumper(): XliffFileDumper + { + return $this->xliffFileDumper ?? $this->xliffFileDumper = $this->createMock(XliffFileDumper::class); + } +} diff --git a/src/Symfony/Component/Translation/Tests/TranslatorBagTest.php b/src/Symfony/Component/Translation/Tests/TranslatorBagTest.php new file mode 100644 index 0000000000..a202bc65ca --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/TranslatorBagTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\TranslatorBag; + +class TranslatorBagTest extends TestCase +{ + public function testAll() + { + $catalogue = new MessageCatalogue('en', $messages = ['domain1' => ['foo' => 'foo'], 'domain2' => ['bar' => 'bar']]); + + $bag = new TranslatorBag(); + $bag->addCatalogue($catalogue); + + $this->assertEquals(['en' => $messages], $this->getAllMessagesFromTranslatorBag($bag)); + + $messages = ['domain1+intl-icu' => ['foo' => 'bar']] + $messages + [ + 'domain2+intl-icu' => ['bar' => 'foo'], + 'domain3+intl-icu' => ['biz' => 'biz'], + ]; + $catalogue = new MessageCatalogue('en', $messages); + + $bag = new TranslatorBag(); + $bag->addCatalogue($catalogue); + + $this->assertEquals([ + 'en' => [ + 'domain1' => ['foo' => 'bar'], + 'domain2' => ['bar' => 'foo'], + 'domain3' => ['biz' => 'biz'], + ], + ], $this->getAllMessagesFromTranslatorBag($bag)); + } + + public function testDiff() + { + $catalogueA = new MessageCatalogue('en', ['domain1' => ['foo' => 'foo', 'bar' => 'bar'], 'domain2' => ['baz' => 'baz', 'qux' => 'qux']]); + + $bagA = new TranslatorBag(); + $bagA->addCatalogue($catalogueA); + + $catalogueB = new MessageCatalogue('en', ['domain1' => ['foo' => 'foo'], 'domain2' => ['baz' => 'baz', 'corge' => 'corge']]); + + $bagB = new TranslatorBag(); + $bagB->addCatalogue($catalogueB); + + $bagResult = $bagA->diff($bagB); + + $this->assertEquals([ + 'en' => [ + 'domain1' => ['bar' => 'bar'], + 'domain2' => ['qux' => 'qux'], + ], + ], $this->getAllMessagesFromTranslatorBag($bagResult)); + } + + public function testIntersect() + { + $catalogueA = new MessageCatalogue('en', ['domain1' => ['foo' => 'foo', 'bar' => 'bar'], 'domain2' => ['baz' => 'baz', 'qux' => 'qux']]); + + $bagA = new TranslatorBag(); + $bagA->addCatalogue($catalogueA); + + $catalogueB = new MessageCatalogue('en', ['domain1' => ['foo' => 'foo', 'baz' => 'baz'], 'domain2' => ['baz' => 'baz', 'corge' => 'corge']]); + + $bagB = new TranslatorBag(); + $bagB->addCatalogue($catalogueB); + + $bagResult = $bagA->intersect($bagB); + + $this->assertEquals([ + 'en' => [ + 'domain1' => ['bar' => 'bar'], + 'domain2' => ['qux' => 'qux'], + ], + ], $this->getAllMessagesFromTranslatorBag($bagResult)); + } + + private function getAllMessagesFromTranslatorBag(TranslatorBag $translatorBag): array + { + $allMessages = []; + foreach ($translatorBag->getCatalogues() as $catalogue) { + $allMessages[$catalogue->getLocale()] = $catalogue->all(); + } + + return $allMessages; + } +} diff --git a/src/Symfony/Component/Translation/Translator.php b/src/Symfony/Component/Translation/Translator.php index e332e13753..b40f13d3de 100644 --- a/src/Symfony/Component/Translation/Translator.php +++ b/src/Symfony/Component/Translation/Translator.php @@ -243,6 +243,14 @@ class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleA return $this->catalogues[$locale]; } + /** + * {@inheritdoc} + */ + public function getCatalogues(): array + { + return array_values($this->catalogues); + } + /** * Gets the loaders. * diff --git a/src/Symfony/Component/Translation/TranslatorBag.php b/src/Symfony/Component/Translation/TranslatorBag.php new file mode 100644 index 0000000000..c6555782fd --- /dev/null +++ b/src/Symfony/Component/Translation/TranslatorBag.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation; + +use Symfony\Component\Translation\Catalogue\AbstractOperation; +use Symfony\Component\Translation\Catalogue\TargetOperation; + +final class TranslatorBag implements TranslatorBagInterface +{ + /** @var MessageCatalogue[] */ + private $catalogues = []; + + public function addCatalogue(MessageCatalogue $catalogue): void + { + if (null !== $existingCatalogue = $this->getCatalogue($catalogue->getLocale())) { + $catalogue->addCatalogue($existingCatalogue); + } + + $this->catalogues[$catalogue->getLocale()] = $catalogue; + } + + public function addBag(TranslatorBagInterface $bag): void + { + foreach ($bag->getCatalogues() as $catalogue) { + $this->addCatalogue($catalogue); + } + } + + /** + * {@inheritdoc} + */ + public function getCatalogue(string $locale = null) + { + if (null === $locale || !isset($this->catalogues[$locale])) { + $this->catalogues[$locale] = new MessageCatalogue($locale); + } + + return $this->catalogues[$locale]; + } + + /** + * {@inheritdoc} + */ + public function getCatalogues(): array + { + return array_values($this->catalogues); + } + + public function diff(TranslatorBagInterface $diffBag): self + { + $diff = new self(); + + foreach ($this->catalogues as $locale => $catalogue) { + if (null === $diffCatalogue = $diffBag->getCatalogue($locale)) { + $diff->addCatalogue($catalogue); + + continue; + } + + $operation = new TargetOperation($diffCatalogue, $catalogue); + $operation->moveMessagesToIntlDomainsIfPossible(AbstractOperation::NEW_BATCH); + $newCatalogue = new MessageCatalogue($locale); + + foreach ($operation->getDomains() as $domain) { + $newCatalogue->add($operation->getNewMessages($domain), $domain); + } + + $diff->addCatalogue($newCatalogue); + } + + return $diff; + } + + public function intersect(TranslatorBagInterface $intersectBag): self + { + $diff = new self(); + + foreach ($this->catalogues as $locale => $catalogue) { + if (null === $intersectCatalogue = $intersectBag->getCatalogue($locale)) { + continue; + } + + $operation = new TargetOperation($catalogue, $intersectCatalogue); + $operation->moveMessagesToIntlDomainsIfPossible(AbstractOperation::OBSOLETE_BATCH); + $obsoleteCatalogue = new MessageCatalogue($locale); + + foreach ($operation->getDomains() as $domain) { + $obsoleteCatalogue->add($operation->getObsoleteMessages($domain), $domain); + } + + $diff->addCatalogue($obsoleteCatalogue); + } + + return $diff; + } +} diff --git a/src/Symfony/Component/Translation/TranslatorBagInterface.php b/src/Symfony/Component/Translation/TranslatorBagInterface.php index e40ca8a23b..4228977352 100644 --- a/src/Symfony/Component/Translation/TranslatorBagInterface.php +++ b/src/Symfony/Component/Translation/TranslatorBagInterface.php @@ -16,6 +16,8 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException; /** * TranslatorBagInterface. * + * @method MessageCatalogueInterface[] getCatalogues() Returns all catalogues of the instance + * * @author Abdellatif Ait boudad */ interface TranslatorBagInterface diff --git a/src/Symfony/Component/Translation/composer.json b/src/Symfony/Component/Translation/composer.json index 3f2c16d3b6..01c05bd7d8 100644 --- a/src/Symfony/Component/Translation/composer.json +++ b/src/Symfony/Component/Translation/composer.json @@ -28,6 +28,7 @@ "symfony/dependency-injection": "^5.0", "symfony/http-kernel": "^5.0", "symfony/intl": "^4.4|^5.0", + "symfony/polyfill-intl-icu": "^1.21", "symfony/service-contracts": "^1.1.2|^2", "symfony/yaml": "^4.4|^5.0", "symfony/finder": "^4.4|^5.0",