Added Translation Providers

Co-authored-by: Olivier Dolbeau <github@a.bbnt.me>
This commit is contained in:
Mathieu Santostefano 2020-04-27 01:21:26 +02:00
parent be384cf221
commit 6e55fa84b7
No known key found for this signature in database
GPG Key ID: EB610773AF2B5B5B
58 changed files with 3616 additions and 51 deletions

View File

@ -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
--------------

View File

@ -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
--------------

2
link
View File

@ -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)));

View File

@ -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
-----

View File

@ -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 <comment>--sort</> flag:
You can dump a tree-like structure using the yaml format with <comment>--as-tree</> flag:
<info>php %command.full_name% --force --output-format=yaml --as-tree=3 en AcmeBundle</info>
<info>php %command.full_name% --force --output-format=yaml --sort=asc --as-tree=3 fr</info>
<info>php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle</info>
<info>php %command.full_name% --force --format=yaml --sort=asc --as-tree=3 fr</info>
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 <info>%s</info>', $input->getOption('xliff-version')));
if ('xlf' === $format) {
$io->comment(sprintf('Xliff output version is <info>%s</info>', $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);

View File

@ -85,6 +85,7 @@ class UnusedTagsPass implements CompilerPassInterface
'translation.dumper',
'translation.extractor',
'translation.loader',
'translation.provider_factory',
'twig.extension',
'twig.loader',
'twig.runtime',

View File

@ -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()

View File

@ -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)

View File

@ -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')

View File

@ -176,6 +176,7 @@
<xsd:element name="path" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="enabled-locale" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="pseudo-localization" type="pseudo_localization" minOccurs="0" maxOccurs="1" />
<xsd:element name="provider" type="translation_provider" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="fallback" type="xsd:string" />
@ -195,6 +196,15 @@
<xsd:attribute name="parse_html" type="xsd:boolean" />
</xsd:complexType>
<xsd:complexType name="translation_provider">
<xsd:sequence>
<xsd:element name="domain" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="locale" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="dsn" type="xsd:string" />
</xsd:complexType>
<xsd:complexType name="validation">
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="static-method" type="xsd:string" />

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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')
;
};

View File

@ -418,6 +418,7 @@ class ConfigurationTest extends TestCase
'parse_html' => false,
'localizable_html_attributes' => [],
],
'providers' => [],
],
'validation' => [
'enabled' => !class_exists(FullStack::class),

View File

@ -0,0 +1,4 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

View File

@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
5.3
---
* Create the bridge

View File

@ -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.

View File

@ -0,0 +1,245 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* 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)));
}
}
}

View File

@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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];
}
}

View File

@ -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)

View File

@ -0,0 +1,39 @@
<?php
namespace Symfony\Component\Translation\Bridge\Loco\Tests;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory;
use Symfony\Component\Translation\Provider\ProviderFactoryInterface;
use Symfony\Component\Translation\Tests\ProviderFactoryTestCase;
class LocoProviderFactoryTest extends ProviderFactoryTestCase
{
public function supportsProvider(): iterable
{
yield [true, 'loco://API_KEY@default'];
yield [false, 'somethingElse://API_KEY@default'];
}
public function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://API_KEY@default'];
}
public function createProvider(): iterable
{
yield [
'loco://localise.biz/api/',
'loco://API_KEY@default',
];
}
public function incompleteDsnProvider(): iterable
{
yield ['loco://default'];
}
public function createFactory(): ProviderFactoryInterface
{
return new LocoProviderFactory($this->getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader());
}
}

View File

@ -0,0 +1,516 @@
<?php
namespace Symfony\Component\Translation\Bridge\Loco\Tests;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProvider;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\Loader\XliffFileLoader;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Provider\ProviderInterface;
use Symfony\Component\Translation\Tests\ProviderTestCase;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class LocoProviderTest extends ProviderTestCase
{
public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface
{
return new LocoProvider($client, $loader, $logger, $defaultLocale, $endpoint);
}
public function testCompleteWriteProcess()
{
$createAssetResponse = $this->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'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Hello</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Welcome, {firstname}!</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
$expectedTranslatorBagEn,
];
$expectedTranslatorBagFr = new TranslatorBag();
$expectedTranslatorBagFr->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr', 'messages'));
yield ['fr', 'messages', <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Bonjour</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Bienvenue, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
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'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Hello</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Welcome, {firstname}!</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'fr' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f67" resname="index.hello" datatype="plaintext">
<source>index.hello</source>
<target state="translated">Bonjour</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e2" resname="index.greetings" datatype="plaintext" extradata="loco:format=icu">
<source>index.greetings</source>
<target state="translated">Bienvenue, {firstname} !</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
],
'validators' => [
'en' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f68" resname="firstname.error" datatype="plaintext">
<source>firstname.error</source>
<target state="translated">Firstname must contains only letters.</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e3" resname="lastname.error" datatype="plaintext" extradata="loco:format=icu">
<source>lastname.error</source>
<target state="translated">Lastname must contains only letters.</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'fr' => <<<'XLIFF'
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="https://localise.biz/user/symfony-translation-provider" source-language="en" datatype="database" tool-id="loco">
<header>
<tool tool-id="loco" tool-name="Loco" tool-version="1.0.25 20201211-1" tool-company="Loco"/>
</header>
<body>
<trans-unit id="loco:5fd89b853ee27904dd6c5f68" resname="firstname.error" datatype="plaintext">
<source>firstname.error</source>
<target state="translated">Le prénom ne peut contenir que des lettres.</target>
</trans-unit>
<trans-unit id="loco:5fd89b8542e5aa5cc27457e3" resname="lastname.error" datatype="plaintext" extradata="loco:format=icu">
<source>lastname.error</source>
<target state="translated">Le nom de famille ne peut contenir que des lettres.</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
],
],
[
'messages' => $expectedTranslatorBagMessages,
'validators' => $expectedTranslatorBagValidators,
],
];
}
}

View File

@ -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"
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony Loco Translation Bridge Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.3
---
* Add `translation:pull` and `translation:push` commands to manage translations with third-party providers
5.2.0
-----

View File

@ -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.

View File

@ -0,0 +1,157 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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 <info>%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 <comment>--force</> flag:
<info>php %command.full_name% --force provider</>
Full example:
<info>php %command.full_name% provider --force --domains=messages,validators --locales=en</>
This command pulls all translations associated with the <comment>messages</> and <comment>validators</> domains for the <comment>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;
}
}

View File

@ -0,0 +1,158 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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 <info>%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 <comment>--force</> flag:
<info>php %command.full_name% --force provider</>
You can delete provider translations which are not present locally by using the <comment>--delete-missing</> flag:
<info>php %command.full_name% --delete-missing provider</>
Full example:
<info>php %command.full_name% provider --force --delete-missing --domains=messages,validators --locales=en</>
This command pushes all translations associated with the <comment>messages</> and <comment>validators</> domains for the <comment>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);
}
}

View File

@ -0,0 +1,78 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -79,6 +79,14 @@ class DataCollectorTranslator implements TranslatorInterface, TranslatorBagInter
return $this->translator->getCatalogue($locale);
}
/**
* {@inheritdoc}
*/
public function getCatalogues(): array
{
return $this->translator->getCatalogues();
}
/**
* {@inheritdoc}
*

View File

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*
* @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;
}
}

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*
* @experimental in 5.3
*/
interface ProviderExceptionInterface extends ExceptionInterface
{
/*
* Returns debug info coming from the Symfony\Contracts\HttpClient\ResponseInterface
*/
public function getDebug(): string;
}

View File

@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.'.');
}
}

View File

@ -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, '<?xml');
}
}

View File

@ -82,6 +82,14 @@ class LoggingTranslator implements TranslatorInterface, TranslatorBagInterface,
return $this->translator->getCatalogue($locale);
}
/**
* {@inheritdoc}
*/
public function getCatalogues(): array
{
return $this->translator->getCatalogues();
}
/**
* Gets the fallback locales.
*

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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;
}
}

View File

@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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;
}
}

View File

@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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
{
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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];
}
}

View File

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}

View File

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @experimental in 5.3
*/
final class TranslationProviderCollection
{
private $providers;
/**
* @param array<string, ProviderInterface> $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);
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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);
}
}

View File

@ -0,0 +1,113 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*/
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 .= <<<XLIFF
<trans-unit id="$key">
<source>$key</source>
<target>$value</target>
</trans-unit>
XLIFF;
}
$xliffContent = <<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="$targetLanguage" datatype="plaintext" original="file.ext">
<body>
$transUnits
</body>
</file>
</xliff>
XLIFF;
} else {
$units = '';
foreach ($messages as $key => $value) {
$units .= <<<XLIFF
<unit id="$key">
<segment>
<source>$key</source>
<target>$value</target>
</segment>
</unit>
XLIFF;
}
$xliffContent = <<<XLIFF
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="$targetLanguage">
<file id="messages.$targetLanguage">
$units
</file>
</xliff>
XLIFF;
}
$filename = sprintf('%s/%s', $this->translationAppDir.'/translations', str_replace('%locale%', $targetLanguage, $fileNamePattern));
file_put_contents($filename, $xliffContent);
$this->files[] = $filename;
return $filename;
}
}

View File

@ -0,0 +1,356 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*/
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(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>newFoo</target>
</trans-unit>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>NOTE</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
, file_get_contents($filenameEn));
$this->assertXmlStringEqualsXmlString(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>nouveauFoo</target>
</trans-unit>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>NOTE</target>
</trans-unit>
</body>
</file>
</xliff>
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(<<<XLIFF
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="en">
<file id="messages.en">
<unit id="994ixRL" name="new.foo">
<segment>
<source>new.foo</source>
<target>newFoo</target>
</segment>
</unit>
<unit id="7bRlYkK" name="note">
<segment>
<source>note</source>
<target>NOTE</target>
</segment>
</unit>
</file>
</xliff>
XLIFF
, file_get_contents($filenameEn));
$this->assertXmlStringEqualsXmlString(<<<XLIFF
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="fr">
<file id="messages.fr">
<unit id="994ixRL" name="new.foo">
<segment>
<source>new.foo</source>
<target>nouveauFoo</target>
</segment>
</unit>
<unit id="7bRlYkK" name="note">
<segment>
<source>note</source>
<target>NOTE</target>
</segment>
</unit>
</file>
</xliff>
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(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>UPDATED NOTE</target>
</trans-unit>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>newFoo</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
, file_get_contents($filenameEn));
$this->assertXmlStringEqualsXmlString(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>NOTE MISE À JOUR</target>
</trans-unit>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>nouveauFoo</target>
</trans-unit>
</body>
</file>
</xliff>
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(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>UPDATED NOTE</target>
</trans-unit>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>newFoo</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
, file_get_contents($filenameEn));
$this->assertXmlStringEqualsXmlString(<<<XLIFF
<?xml version="1.0"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="7bRlYkK" resname="note">
<source>note</source>
<target>NOTE MISE À JOUR</target>
</trans-unit>
<trans-unit id="994ixRL" resname="new.foo">
<source>new.foo</source>
<target>nouveauFoo</target>
</trans-unit>
</body>
</file>
</xliff>
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'));
}
}

View File

@ -0,0 +1,253 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*/
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'));
}
}

View File

@ -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 = [

View File

@ -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 = <<<XLIFF
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" datatype="plaintext" original="file.ext">
<body>
<trans-unit id="1">
<source>foo</source>
<target>bar</target>
</trans-unit>
<trans-unit id="2">
<source>extra</source>
</trans-unit>
<trans-unit id="3">
<source>key</source>
<target></target>
</trans-unit>
<trans-unit id="4">
<source>test</source>
<target>with</target>
<note>note</note>
</trans-unit>
</body>
</file>
</xliff>
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);

View File

@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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'));
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*
* @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', '')));
}
}

View File

@ -0,0 +1,145 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*/
abstract class ProviderFactoryTestCase extends TestCase
{
protected $client;
protected $logger;
protected $defaultLocale;
protected $loader;
protected $xliffFileDumper;
abstract public function createFactory(): ProviderFactoryInterface;
/**
* @return iterable<array{0: bool, 1: string}>
*/
abstract public function supportsProvider(): iterable;
/**
* @return iterable<array{0: string, 1: string, 2: TransportInterface}>
*/
abstract public function createProvider(): iterable;
/**
* @return iterable<array{0: string, 1: string|null}>
*/
public function unsupportedSchemeProvider(): iterable
{
return [];
}
/**
* @return iterable<array{0: string, 1: string|null}>
*/
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);
}
}

View File

@ -0,0 +1,75 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <msantostefano@protonmail.com>
*/
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<array{0: string, 1: ProviderInterface}>
*/
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);
}
}

View File

@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -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.
*

View File

@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -16,6 +16,8 @@ use Symfony\Component\Translation\Exception\InvalidArgumentException;
/**
* TranslatorBagInterface.
*
* @method MessageCatalogueInterface[] getCatalogues() Returns all catalogues of the instance
*
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
interface TranslatorBagInterface

View File

@ -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",