Added Lokalise Provider

This commit is contained in:
Mathieu Santostefano 2021-04-23 15:44:47 +02:00
parent af19b6bec2
commit 022d8285f3
No known key found for this signature in database
GPG Key ID: EB610773AF2B5B5B
14 changed files with 1175 additions and 0 deletions

View File

@ -171,6 +171,7 @@ use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory;
use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory;
use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProviderFactory;
use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory;
use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand;
use Symfony\Component\Translation\PseudoLocalizationTranslator;
@ -1345,6 +1346,7 @@ class FrameworkExtension extends Extension
$classToServices = [
CrowdinProviderFactory::class => 'translation.provider_factory.crowdin',
LocoProviderFactory::class => 'translation.provider_factory.loco',
LokaliseProviderFactory::class => 'translation.provider_factory.lokalise',
PoEditorProviderFactory::class => 'translation.provider_factory.poeditor',
];

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory;
use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory;
use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProviderFactory;
use Symfony\Component\Translation\Bridge\PoEditor\PoEditorProviderFactory;
use Symfony\Component\Translation\Provider\NullProviderFactory;
use Symfony\Component\Translation\Provider\TranslationProviderCollection;
@ -54,6 +55,15 @@ return static function (ContainerConfigurator $container) {
])
->tag('translation.provider_factory')
->set('translation.provider_factory.lokalise', LokaliseProviderFactory::class)
->args([
service('http_client'),
service('logger'),
param('kernel.default_locale'),
service('translation.loader.xliff'),
])
->tag('translation.provider_factory')
->set('translation.provider_factory.poeditor', PoEditorProviderFactory::class)
->args([
service('http_client'),

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,3 @@
vendor/
composer.lock
phpunit.xml

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,343 @@
<?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\Lokalise;
use Psr\Log\LoggerInterface;
use Symfony\Component\Translation\Exception\ProviderException;
use Symfony\Component\Translation\Loader\LoaderInterface;
use Symfony\Component\Translation\MessageCatalogueInterface;
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 Lokalise:
* * Filenames refers to Symfony's translation domains;
* * Keys refers to Symfony's translation keys;
* * Translations refers to Symfony's translated messages
*
* @experimental in 5.3
*/
final class LokaliseProvider 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('lokalise://%s', $this->endpoint);
}
/**
* {@inheritdoc}
*
* Lokalise API recommends sending payload in chunks of up to 500 keys per request.
*
* @see https://app.lokalise.com/api2docs/curl/#transition-create-keys-post
*/
public function write(TranslatorBagInterface $translatorBag): void
{
$defaultCatalogue = $translatorBag->getCatalogue($this->defaultLocale);
if (!$defaultCatalogue) {
$defaultCatalogue = $translatorBag->getCatalogues()[0];
}
$this->ensureAllLocalesAreCreated($translatorBag);
$existingKeysByDomain = [];
foreach ($defaultCatalogue->getDomains() as $domain) {
if (!\array_key_exists($domain, $existingKeysByDomain)) {
$existingKeysByDomain[$domain] = [];
}
$existingKeysByDomain[$domain] += $this->getKeysIds(array_keys($defaultCatalogue->all($domain)), $domain);
}
$keysToCreate = $createdKeysByDomain = [];
foreach ($existingKeysByDomain as $domain => $existingKeys) {
$allKeysForDomain = array_keys($defaultCatalogue->all($domain));
foreach (array_keys($existingKeys) as $keyName) {
unset($allKeysForDomain[$keyName]);
}
$keysToCreate[$domain] = $allKeysForDomain;
}
foreach ($keysToCreate as $domain => $keys) {
$createdKeysByDomain[$domain] = $this->createKeys($keys, $domain);
}
$this->updateTranslations(array_merge($createdKeysByDomain, $existingKeysByDomain), $translatorBag);
}
public function read(array $domains, array $locales): TranslatorBag
{
$translatorBag = new TranslatorBag();
$translations = $this->exportFiles($locales, $domains);
foreach ($translations as $locale => $files) {
foreach ($files as $filename => $content) {
$translatorBag->addCatalogue($this->loader->load($content['content'], $locale, str_replace('.xliff', '', $filename)));
}
}
return $translatorBag;
}
public function delete(TranslatorBagInterface $translatorBag): void
{
$catalogue = $translatorBag->getCatalogue($this->defaultLocale);
if (!$catalogue) {
$catalogue = $translatorBag->getCatalogues()[0];
}
$keysIds = [];
foreach ($catalogue->getDomains() as $domain) {
$keysToDelete = [];
foreach (array_keys($catalogue->all($domain)) as $key) {
$keysToDelete[] = $key;
}
$keysIds += $this->getKeysIds($keysToDelete, $domain);
}
$response = $this->client->request('DELETE', 'keys', [
'json' => ['keys' => array_values($keysIds)],
]);
if (200 !== $response->getStatusCode()) {
throw new ProviderException(sprintf('Unable to delete keys from Lokalise: "%s".', $response->getContent(false)), $response);
}
}
/**
* @see https://app.lokalise.com/api2docs/curl/#transition-download-files-post
*/
private function exportFiles(array $locales, array $domains): array
{
$response = $this->client->request('POST', 'files/export', [
'json' => [
'format' => 'symfony_xliff',
'original_filenames' => true,
'directory_prefix' => '%LANG_ISO%',
'filter_langs' => array_values($locales),
'filter_filenames' => array_map([$this, 'getLokaliseFilenameFromDomain'], $domains),
],
]);
$responseContent = $response->toArray(false);
if (406 === $response->getStatusCode()
&& 'No keys found with specified filenames.' === $responseContent['error']['message']
) {
return [];
}
if (200 !== $response->getStatusCode()) {
throw new ProviderException(sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
}
return $responseContent['files'];
}
private function createKeys(array $keys, string $domain): array
{
$keysToCreate = [];
foreach ($keys as $key) {
$keysToCreate[] = [
'key_name' => $key,
'platforms' => ['web'],
'filenames' => [
'web' => $this->getLokaliseFilenameFromDomain($domain),
// There is a bug in Lokalise with "Per platform key names" option enabled,
// we need to provide a filename for all platforms.
'ios' => null,
'android' => null,
'other' => null,
],
];
}
$chunks = array_chunk($keysToCreate, 500);
$responses = [];
foreach ($chunks as $chunk) {
$responses[] = $this->client->request('POST', 'keys', [
'json' => ['keys' => $chunk],
]);
}
$createdKeys = [];
foreach ($responses as $response) {
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to create keys to Lokalise: "%s".', $response->getContent(false)));
continue;
}
$createdKeys = array_reduce($response->toArray(false)['keys'], function ($carry, array $keyItem) {
$carry[$keyItem['key_name']['web']] = $keyItem['key_id'];
return $carry;
}, $createdKeys);
}
return $createdKeys;
}
/**
* Translations will be created for keys without existing translations.
* Translations will be updated for keys with existing translations.
*/
private function updateTranslations(array $keysByDomain, TranslatorBagInterface $translatorBag)
{
$keysToUpdate = [];
foreach ($keysByDomain as $domain => $keys) {
foreach ($keys as $keyName => $keyId) {
$keysToUpdate[] = [
'key_id' => $keyId,
'platforms' => ['web'],
'filenames' => [
'web' => $this->getLokaliseFilenameFromDomain($domain),
'ios' => null,
'android' => null,
'other' => null,
],
'translations' => array_reduce($translatorBag->getCatalogues(), function ($carry, MessageCatalogueInterface $catalogue) use ($keyName, $domain) {
// Message could be not found because the catalogue is empty.
// We must not send the key in place of the message to avoid wrong message update on the provider.
if ($catalogue->get($keyName, $domain) !== $keyName) {
$carry[] = [
'language_iso' => $catalogue->getLocale(),
'translation' => $catalogue->get($keyName, $domain),
];
}
return $carry;
}, []),
];
}
}
$chunks = array_chunk($keysToUpdate, 500);
$responses = [];
foreach ($chunks as $chunk) {
$responses[] = $this->client->request('PUT', 'keys', [
'json' => ['keys' => $chunk],
]);
}
foreach ($responses as $response) {
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to create/update translations to Lokalise: "%s".', $response->getContent(false)));
}
}
}
private function getKeysIds(array $keys, string $domain): array
{
$response = $this->client->request('GET', 'keys', [
'query' => [
'filter_keys' => implode(',', $keys),
'filter_filenames' => $this->getLokaliseFilenameFromDomain($domain),
],
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to get keys ids from Lokalise: "%s".', $response->getContent(false)));
}
return array_reduce($response->toArray(false)['keys'], function ($carry, array $keyItem) {
$carry[$keyItem['key_name']['web']] = $keyItem['key_id'];
return $carry;
}, []);
}
private function ensureAllLocalesAreCreated(TranslatorBagInterface $translatorBag)
{
$providerLanguages = $this->getLanguages();
$missingLanguages = array_reduce($translatorBag->getCatalogues(), function ($carry, $catalogue) use ($providerLanguages) {
if (!\in_array($catalogue->getLocale(), $providerLanguages)) {
$carry[] = $catalogue->getLocale();
}
return $carry;
}, []);
if ($missingLanguages) {
$this->createLanguages($missingLanguages);
}
}
private function getLanguages(): array
{
$response = $this->client->request('GET', 'languages');
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to get languages from Lokalise: "%s".', $response->getContent(false)));
return [];
}
$responseContent = $response->toArray(false);
if (\array_key_exists('languages', $responseContent)) {
return array_map(function ($language) {
return $language['lang_iso'];
}, $responseContent['languages']);
}
return [];
}
private function createLanguages(array $languages): void
{
$response = $this->client->request('POST', 'languages', [
'json' => [
'languages' => array_map(function ($language) {
return ['lang_iso' => $language];
}, $languages),
],
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to create languages on Lokalise: "%s".', $response->getContent(false)));
}
}
private function getLokaliseFilenameFromDomain(string $domain): string
{
return sprintf('%s.xliff', $domain);
}
}

View File

@ -0,0 +1,68 @@
<?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\Lokalise;
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 LokaliseProviderFactory extends AbstractProviderFactory
{
private const HOST = 'api.lokalise.com';
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 LokaliseProvider
*/
public function create(Dsn $dsn): ProviderInterface
{
if ('lokalise' !== $dsn->getScheme()) {
throw new UnsupportedSchemeException($dsn, 'lokalise', $this->getSupportedSchemes());
}
$endpoint = sprintf('%s%s', 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(), $dsn->getPort() ? ':'.$dsn->getPort() : '');
$client = $this->client->withOptions([
'base_uri' => sprintf('https://%sprojects/%s/api2/', $endpoint, $this->getUser($dsn)),
'headers' => [
'X-Api-Token' => $this->getPassword($dsn),
],
]);
return new LokaliseProvider($client, $this->loader, $this->logger, $this->defaultLocale, $endpoint);
}
protected function getSupportedSchemes(): array
{
return ['lokalise'];
}
}

View File

@ -0,0 +1,28 @@
Lokalise Translation Provider
=============================
Provides Lokalise integration for Symfony Translation.
DSN example
-----------
```
// .env file
LOKALISE_DSN=lokalise://PROJECT_ID:API_KEY@default
```
where:
- `PROJECT_ID` is your Lokalise Project ID
- `API_KEY` is your Lokalise API key
Go to the Project Settings in Lokalise to find the Project ID.
[Generate an API key on Lokalise](https://app.lokalise.com/api2docs/curl/#resource-authentication)
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\Lokalise\Tests;
use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProviderFactory;
use Symfony\Component\Translation\Provider\ProviderFactoryInterface;
use Symfony\Component\Translation\Test\ProviderFactoryTestCase;
class LokaliseProviderFactoryTest extends ProviderFactoryTestCase
{
public function supportsProvider(): iterable
{
yield [true, 'lokalise://PROJECT_ID:API_KEY@default'];
yield [false, 'somethingElse://PROJECT_ID:API_KEY@default'];
}
public function unsupportedSchemeProvider(): iterable
{
yield ['somethingElse://PROJECT_ID:API_KEY@default'];
}
public function createProvider(): iterable
{
yield [
'lokalise://api.lokalise.com',
'lokalise://PROJECT_ID:API_KEY@default',
];
}
public function incompleteDsnProvider(): iterable
{
yield ['lokalise://default'];
}
public function createFactory(): ProviderFactoryInterface
{
return new LokaliseProviderFactory($this->getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader());
}
}

View File

@ -0,0 +1,584 @@
<?php
namespace Symfony\Component\Translation\Bridge\Loco\Tests;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProvider;
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\Test\ProviderTestCase;
use Symfony\Component\Translation\TranslatorBag;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class LokaliseProviderTest extends ProviderTestCase
{
public function createProvider(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint): ProviderInterface
{
return new LokaliseProvider($client, $loader, $logger, $defaultLocale, $endpoint);
}
public function toStringProvider(): iterable
{
yield [
$this->createProvider($this->getClient()->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com'),
'lokalise://api.lokalise.com',
];
yield [
$this->createProvider($this->getClient()->withOptions([
'base_uri' => 'https://example.com',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com'),
'lokalise://example.com',
];
yield [
$this->createProvider($this->getClient()->withOptions([
'base_uri' => 'https://example.com:99',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'example.com:99'),
'lokalise://example.com:99',
];
}
public function testCompleteWriteProcess()
{
$getLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url);
return new MockResponse(json_encode(['languages' => []]));
};
$createLanguagesResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedBody = json_encode([
'languages' => [
['lang_iso' => 'en'],
['lang_iso' => 'fr'],
],
]);
$this->assertSame('POST', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/languages', $url);
$this->assertSame($expectedBody, $options['body']);
return new MockResponse();
};
$getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedQuery = [
'filter_keys' => 'a',
'filter_filenames' => 'messages.xliff',
];
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url);
$this->assertSame($expectedQuery, $options['query']);
return new MockResponse(json_encode(['keys' => []]));
};
$getKeysIdsForValidatorsDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedQuery = [
'filter_keys' => 'post.num_comments',
'filter_filenames' => 'validators.xliff',
];
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url);
$this->assertSame($expectedQuery, $options['query']);
return new MockResponse(json_encode(['keys' => []]));
};
$createKeysForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedBody = json_encode([
'keys' => [
[
'key_name' => 'a',
'platforms' => ['web'],
'filenames' => [
'web' => 'messages.xliff',
'ios' => null,
'android' => null,
'other' => null,
],
],
],
]);
$this->assertSame('POST', $method);
$this->assertSame($expectedBody, $options['body']);
return new MockResponse(json_encode(['keys' => [
[
'key_name' => ['web' => 'a'],
'key_id' => 29,
],
]]));
};
$createKeysForValidatorsDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedBody = json_encode([
'keys' => [
[
'key_name' => 'post.num_comments',
'platforms' => ['web'],
'filenames' => [
'web' => 'validators.xliff',
'ios' => null,
'android' => null,
'other' => null,
],
],
],
]);
$this->assertSame('POST', $method);
$this->assertSame($expectedBody, $options['body']);
return new MockResponse(json_encode(['keys' => [
[
'key_name' => ['web' => 'post.num_comments'],
'key_id' => 92,
],
]]));
};
$updateTranslationsResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedBody = json_encode([
'keys' => [
[
'key_id' => 29,
'platforms' => ['web'],
'filenames' => [
'web' => 'messages.xliff',
'ios' => null,
'android' => null,
'other' => null,
],
'translations' => [
[
'language_iso' => 'en',
'translation' => 'trans_en_a',
],
[
'language_iso' => 'fr',
'translation' => 'trans_fr_a',
],
],
],
[
'key_id' => 92,
'platforms' => ['web'],
'filenames' => [
'web' => 'validators.xliff',
'ios' => null,
'android' => null,
'other' => null,
],
'translations' => [
[
'language_iso' => 'en',
'translation' => '{count, plural, one {# comment} other {# comments}}',
],
[
'language_iso' => 'fr',
'translation' => '{count, plural, one {# commentaire} other {# commentaires}}',
],
],
],
],
]);
$this->assertSame('PUT', $method);
$this->assertSame($expectedBody, $options['body']);
return new MockResponse();
};
$provider = $this->createProvider((new MockHttpClient([
$getLanguagesResponse,
$createLanguagesResponse,
$getKeysIdsForMessagesDomainResponse,
$getKeysIdsForValidatorsDomainResponse,
$createKeysForMessagesDomainResponse,
$createKeysForValidatorsDomainResponse,
$updateTranslationsResponse,
]))->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $this->getLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
$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->write($translatorBag);
}
/**
* @dataProvider getResponsesForOneLocaleAndOneDomain
*/
public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag)
{
$response = function (string $method, string $url, array $options = []) use ($locale, $domain, $responseContent): ResponseInterface {
$expectedBody = json_encode([
'format' => 'symfony_xliff',
'original_filenames' => true,
'directory_prefix' => '%LANG_ISO%',
'filter_langs' => [$locale],
'filter_filenames' => [$domain.'.xliff'],
]);
$this->assertSame('POST', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/files/export', $url);
$this->assertSame($expectedBody, $options['body']);
return new MockResponse(json_encode([
'files' => [
$locale => [
$domain.'.xliff' => [
'content' => $responseContent,
],
],
],
]));
};
$loader = $this->getLoader();
$loader->expects($this->once())
->method('load')
->willReturn((new XliffFileLoader())->load($responseContent, $locale, $domain));
$provider = $this->createProvider((new MockHttpClient($response))->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $loader, $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
$translatorBag = $provider->read([$domain], [$locale]);
// We don't want to assert equality of metadata here, due to the ArrayLoader usage.
foreach ($translatorBag->getCatalogues() as $catalogue) {
$catalogue->deleteMetadata('', '');
}
$this->assertEquals($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues());
}
/**
* @dataProvider getResponsesForManyLocalesAndManyDomains
*/
public function testReadForManyLocalesAndManyDomains(array $locales, array $domains, array $responseContents, TranslatorBag $expectedTranslatorBag)
{
$consecutiveLoadArguments = [];
$consecutiveLoadReturns = [];
$response = new MockResponse(json_encode([
'files' => array_reduce($locales, function ($carry, $locale) use ($domains, $responseContents, &$consecutiveLoadArguments, &$consecutiveLoadReturns) {
$carry[$locale] = array_reduce($domains, function ($carry, $domain) use ($locale, $responseContents, &$consecutiveLoadArguments, &$consecutiveLoadReturns) {
$carry[$domain.'.xliff'] = [
'content' => $responseContents[$locale][$domain],
];
$consecutiveLoadArguments[] = [$responseContents[$locale][$domain], $locale, $domain];
$consecutiveLoadReturns[] = (new XliffFileLoader())->load($responseContents[$locale][$domain], $locale, $domain);
return $carry;
}, []);
return $carry;
}, []),
]));
$loader = $this->getLoader();
$loader->expects($this->exactly(\count($consecutiveLoadArguments)))
->method('load')
->withConsecutive(...$consecutiveLoadArguments)
->willReturnOnConsecutiveCalls(...$consecutiveLoadReturns);
$provider = $this->createProvider((new MockHttpClient($response))->withOptions([
'base_uri' => 'https://api.lokalise.com/api2/projects/PROJECT_ID/',
'headers' => ['X-Api-Token' => 'API_KEY'],
]), $loader, $this->getLogger(), $this->getDefaultLocale(), 'api.lokalise.com');
$translatorBag = $provider->read($domains, $locales);
// We don't want to assert equality of metadata here, due to the ArrayLoader usage.
foreach ($translatorBag->getCatalogues() as $catalogue) {
$catalogue->deleteMetadata('', '');
}
foreach ($locales as $locale) {
foreach ($domains as $domain) {
$this->assertEquals($expectedTranslatorBag->getCatalogue($locale)->all($domain), $translatorBag->getCatalogue($locale)->all($domain));
}
}
}
public function testDeleteProcess()
{
$getKeysIdsForMessagesDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedQuery = [
'filter_keys' => 'a',
'filter_filenames' => 'messages.xliff',
];
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url);
$this->assertSame($expectedQuery, $options['query']);
return new MockResponse(json_encode(['keys' => [
[
'key_name' => ['web' => 'a'],
'key_id' => 29,
],
]]));
};
$getKeysIdsForValidatorsDomainResponse = function (string $method, string $url, array $options = []): ResponseInterface {
$expectedQuery = [
'filter_keys' => 'post.num_comments',
'filter_filenames' => 'validators.xliff',
];
$this->assertSame('GET', $method);
$this->assertSame('https://api.lokalise.com/api2/projects/PROJECT_ID/keys?'.http_build_query($expectedQuery), $url);
$this->assertSame($expectedQuery, $options['query']);
return new MockResponse(json_encode(['keys' => [
[
'key_name' => ['web' => 'post.num_comments'],
'key_id' => 92,
],
]]));
};
$deleteResponse = function (string $method, string $url, array $options = []): MockResponse {
$this->assertSame('DELETE', $method);
$this->assertSame(json_encode(['keys' => [29, 92]]), $options['body']);
return new MockResponse();
};
$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([
$getKeysIdsForMessagesDomainResponse,
$getKeysIdsForValidatorsDomainResponse,
$deleteResponse,
], 'https://api.lokalise.com/api2/projects/PROJECT_ID/'),
$this->getLoader(),
$this->getLogger(),
$this->getDefaultLocale(),
'api.lokalise.com'
);
$provider->delete($translatorBag);
}
public function getResponsesForOneLocaleAndOneDomain(): \Generator
{
$arrayLoader = new ArrayLoader();
$expectedTranslatorBagEn = new TranslatorBag();
$expectedTranslatorBagEn->addCatalogue($arrayLoader->load([
'index.hello' => 'Hello',
'index.greetings' => 'Welcome, {firstname}!',
], 'en'));
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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="en">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="index.greetings" resname="index.greetings">
<source>index.greetings</source>
<target>Welcome, {firstname}!</target>
</trans-unit>
<trans-unit id="index.hello" resname="index.hello">
<source>index.hello</source>
<target>Hello</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
$expectedTranslatorBagEn,
];
$expectedTranslatorBagFr = new TranslatorBag();
$expectedTranslatorBagFr->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr'));
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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="fr">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="index.greetings" resname="index.greetings">
<source>index.greetings</source>
<target>Bienvenue, {firstname} !</target>
</trans-unit>
<trans-unit id="index.hello" resname="index.hello">
<source>index.hello</source>
<target>Bonjour</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
$expectedTranslatorBagFr,
];
}
public function getResponsesForManyLocalesAndManyDomains(): \Generator
{
$arrayLoader = new ArrayLoader();
$expectedTranslatorBag = new TranslatorBag();
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'index.hello' => 'Hello',
'index.greetings' => 'Welcome, {firstname}!',
], 'en'));
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr'));
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'firstname.error' => 'Firstname must contains only letters.',
'lastname.error' => 'Lastname must contains only letters.',
], 'en', 'validators'));
$expectedTranslatorBag->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'],
[
'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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="en">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="index.greetings" resname="index.greetings">
<source>index.greetings</source>
<target>Welcome, {firstname}!</target>
</trans-unit>
<trans-unit id="index.hello" resname="index.hello">
<source>index.hello</source>
<target>Hello</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'validators' => <<<'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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="en">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="lastname.error" resname="lastname.error">
<source>lastname.error</source>
<target>Lastname must contains only letters.</target>
</trans-unit>
<trans-unit id="firstname.error" resname="firstname.error">
<source>firstname.error</source>
<target>Firstname must contains only letters.</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
],
'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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="fr">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="index.greetings" resname="index.greetings">
<source>index.greetings</source>
<target>Bienvenue, {firstname} !</target>
</trans-unit>
<trans-unit id="index.hello" resname="index.hello">
<source>index.hello</source>
<target>Bonjour</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
'validators' => <<<'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="" datatype="plaintext" xml:space="preserve" source-language="en" target-language="fr">
<header>
<tool tool-id="lokalise.com" tool-name="Lokalise"/>
</header>
<body>
<trans-unit id="lastname.error" resname="lastname.error">
<source>lastname.error</source>
<target>Le nom de famille ne peut contenir que des lettres.</target>
</trans-unit>
<trans-unit id="firstname.error" resname="firstname.error">
<source>firstname.error</source>
<target>Le prénom ne peut contenir que des lettres.</target>
</trans-unit>
</body>
</file>
</xliff>
XLIFF
,
],
],
$expectedTranslatorBag,
];
}
}

View File

@ -0,0 +1,33 @@
{
"name": "symfony/lokalise-translation-provider",
"type": "symfony-bridge",
"description": "Symfony Lokalise Translation Provider Bridge",
"keywords": ["lokalise", "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"
},
"require-dev": {
"symfony/config": "^4.4|^5.2"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Translation\\Bridge\\Lokalise\\": "" },
"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 Lokalise Translation Provider 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

@ -25,6 +25,10 @@ class UnsupportedSchemeException extends LogicException
'class' => Bridge\Loco\LocoProviderFactory::class,
'package' => 'symfony/loco-translation-provider',
],
'lokalise' => [
'class' => Bridge\Lokalise\LokaliseProviderFactory::class,
'package' => 'symfony/lokalise-translation-provider',
],
'poeditor' => [
'class' => Bridge\PoEditor\PoEditorProviderFactory::class,
'package' => 'symfony/po-editor-translation-provider',