From 022d8285f3cec5bc9c9d9f4897f6d7b75c63878e Mon Sep 17 00:00:00 2001 From: Mathieu Santostefano Date: Fri, 23 Apr 2021 15:44:47 +0200 Subject: [PATCH] Added Lokalise Provider --- .../FrameworkExtension.php | 2 + .../config/translation_providers.php | 10 + .../Bridge/Lokalise/.gitattributes | 4 + .../Translation/Bridge/Lokalise/.gitignore | 3 + .../Translation/Bridge/Lokalise/CHANGELOG.md | 7 + .../Translation/Bridge/Lokalise/LICENSE | 19 + .../Bridge/Lokalise/LokaliseProvider.php | 343 ++++++++++ .../Lokalise/LokaliseProviderFactory.php | 68 ++ .../Translation/Bridge/Lokalise/README.md | 28 + .../Tests/LokaliseProviderFactoryTest.php | 39 ++ .../Lokalise/Tests/LokaliseProviderTest.php | 584 ++++++++++++++++++ .../Translation/Bridge/Lokalise/composer.json | 33 + .../Bridge/Lokalise/phpunit.xml.dist | 31 + .../Exception/UnsupportedSchemeException.php | 4 + 14 files changed, 1175 insertions(+) create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/.gitignore create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/CHANGELOG.md create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProviderFactory.php create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/README.md create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderFactoryTest.php create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/composer.json create mode 100644 src/Symfony/Component/Translation/Bridge/Lokalise/phpunit.xml.dist diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 910a083f79..ff26312582 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -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', ]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index 3633723579..45f8e6363a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -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'), diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes b/src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/.gitignore b/src/Symfony/Component/Translation/Bridge/Lokalise/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/Lokalise/CHANGELOG.md new file mode 100644 index 0000000000..bbb9efcaeb --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Create the bridge diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE b/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php new file mode 100644 index 0000000000..7691ed186f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php @@ -0,0 +1,343 @@ + + * + * 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 + * + * 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); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProviderFactory.php new file mode 100644 index 0000000000..7d10ea4ac1 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProviderFactory.php @@ -0,0 +1,68 @@ + + * + * 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 + * + * @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']; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/README.md b/src/Symfony/Component/Translation/Bridge/Lokalise/README.md new file mode 100644 index 0000000000..063996363e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/README.md @@ -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) diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderFactoryTest.php b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderFactoryTest.php new file mode 100644 index 0000000000..ed7ef71ed2 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderFactoryTest.php @@ -0,0 +1,39 @@ +getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader()); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php new file mode 100644 index 0000000000..06b4620164 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderTest.php @@ -0,0 +1,584 @@ +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' + + + +
+ +
+ + + index.greetings + Welcome, {firstname}! + + + index.hello + Hello + + +
+
+XLIFF + , + $expectedTranslatorBagEn, + ]; + + $expectedTranslatorBagFr = new TranslatorBag(); + $expectedTranslatorBagFr->addCatalogue($arrayLoader->load([ + 'index.hello' => 'Bonjour', + 'index.greetings' => 'Bienvenue, {firstname} !', + ], 'fr')); + + yield ['fr', 'messages', <<<'XLIFF' + + + +
+ +
+ + + index.greetings + Bienvenue, {firstname} ! + + + index.hello + Bonjour + + +
+
+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' + + + +
+ +
+ + + index.greetings + Welcome, {firstname}! + + + index.hello + Hello + + +
+
+XLIFF + , + 'validators' => <<<'XLIFF' + + + +
+ +
+ + + lastname.error + Lastname must contains only letters. + + + firstname.error + Firstname must contains only letters. + + +
+
+XLIFF + , + ], + 'fr' => [ + 'messages' => <<<'XLIFF' + + + +
+ +
+ + + index.greetings + Bienvenue, {firstname} ! + + + index.hello + Bonjour + + +
+
+XLIFF + , + 'validators' => <<<'XLIFF' + + + +
+ +
+ + + lastname.error + Le nom de famille ne peut contenir que des lettres. + + + firstname.error + Le prénom ne peut contenir que des lettres. + + +
+
+XLIFF + , + ], + ], + $expectedTranslatorBag, + ]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/composer.json b/src/Symfony/Component/Translation/Bridge/Lokalise/composer.json new file mode 100644 index 0000000000..589207863f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/composer.json @@ -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" +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/phpunit.xml.dist b/src/Symfony/Component/Translation/Bridge/Lokalise/phpunit.xml.dist new file mode 100644 index 0000000000..f268284f5e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php index 39d4857b1b..b59ba81c09 100644 --- a/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Translation/Exception/UnsupportedSchemeException.php @@ -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',