diff --git a/.github/get-modified-packages.php b/.github/get-modified-packages.php index b78d103b9f2ce..66afe8250fc2e 100644 --- a/.github/get-modified-packages.php +++ b/.github/get-modified-packages.php @@ -12,6 +12,11 @@ $allPackages = json_decode($_SERVER['argv'][1], true, 512, \JSON_THROW_ON_ERROR); $modifiedFiles = json_decode($_SERVER['argv'][2], true, 512, \JSON_THROW_ON_ERROR); +// Sort to get the longest name first (match bridge not component) +usort($allPackages, function($a, $b) { + return strlen($b) - strlen($a); +}); + function isComponentBridge(string $packageDir): bool { return 0 < preg_match('@Symfony/Component/.*/Bridge/@', $packageDir); diff --git a/.github/workflows/package-tests.yml b/.github/workflows/package-tests.yml index cb66e2d8d3b03..fb460a25e3b73 100644 --- a/.github/workflows/package-tests.yml +++ b/.github/workflows/package-tests.yml @@ -16,6 +16,11 @@ jobs: - name: Fetch branch from where the PR started run: git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* + - name: Debug + run: | + echo $(find src/Symfony -mindepth 2 -type f -name composer.json -printf '%h\n') + echo $(git diff --name-only origin/${{ github.base_ref }} HEAD | grep src/) + - name: Find packages id: find-packages run: echo "::set-output name=packages::$(php .github/get-modified-packages.php $(find src/Symfony -mindepth 2 -type f -name composer.json -printf '%h\n' | jq -R -s -c 'split("\n")[:-1]') $(git diff --name-only origin/${{ github.base_ref }} HEAD | grep src/ | jq -R -s -c 'split("\n")[:-1]'))" diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 9cddc28f86f21..e0cce5035d7c0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -170,6 +170,7 @@ use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\Translation\Bridge\Loco\Provider\LocoProviderFactory; +use Symfony\Component\Translation\Bridge\Lokalise\Provider\LokaliseProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1355,14 +1356,18 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $classToServices = [ LocoProviderFactory::class => 'translation.provider_factory.loco', + LokaliseProviderFactory::class => 'translation.provider_factory.lokalise', ]; $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.'))); + switch ($package = substr($service, \strlen('translation.provider_factory.'))) { + case 'loco': $package = 'loco'; break; + case 'lokalise': $package = 'lokalise'; break; + } - if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable($package, $class, $parentPackages)) { + if (!$container->hasDefinition('http_client') || !ContainerBuilder::willBeAvailable(sprintf('symfony/%s-translation', $package), $class, $parentPackages)) { $container->removeDefinition($service); } } diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes b/src/Symfony/Component/Translation/Bridge/Lokalise/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /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 0000000000000..c49a5d8df5c65 --- /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 0000000000000..bbb9efcaeb29b --- /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 0000000000000..efb17f98e7dd3 --- /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/Provider/LokaliseProvider.php b/src/Symfony/Component/Translation/Bridge/Lokalise/Provider/LokaliseProvider.php new file mode 100644 index 0000000000000..9ebe78757fac1 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Provider/LokaliseProvider.php @@ -0,0 +1,228 @@ + + * + * 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\Provider; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\ProviderException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + * + * In Lokalise: + * * Filenames refers to Symfony's translation domains; + * * Keys refers to Symfony's translation keys; + * * Translations refers to Symfony's translated messages + */ +final class LokaliseProvider implements ProviderInterface +{ + private $projectId; + private $client; + private $loader; + private $logger; + private $defaultLocale; + private $endpoint; + + public function __construct(string $projectId, HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint) + { + $this->projectId = $projectId; + $this->client = $client; + $this->loader = $loader; + $this->logger = $logger; + $this->defaultLocale = $defaultLocale; + $this->endpoint = $endpoint; + } + + public function __toString(): string + { + return sprintf('%s://%s', LokaliseProviderFactory::SCHEME, $this->endpoint); + } + + public function getName(): string + { + return LokaliseProviderFactory::SCHEME; + } + + /** + * {@inheritdoc} + */ + public function write(TranslatorBagInterface $translatorBag): void + { + $this->createKeysWithTranslations($translatorBag); + } + + public function read(array $domains, array $locales): TranslatorBagInterface + { + $translatorBag = new TranslatorBag(); + $translations = $this->exportFiles($locales, $domains); + + foreach ($translations as $locale => $files) { + foreach ($files as $filename => $content) { + $intlDomain = $domain = str_replace('.xliff', '', $filename); + $suffixLength = \strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX); + if (\strlen($domain) > $suffixLength && false !== strpos($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX, -$suffixLength)) { + $intlDomain .= MessageCatalogue::INTL_DOMAIN_SUFFIX; + } + + if (\in_array($intlDomain, $domains, true)) { + $translatorBag->addCatalogue($this->loader->load($content['content'], $locale, $intlDomain)); + } else { + $this->logger->info(sprintf('The translations fetched from Lokalise under the filename "%s" does not match with any domains of your application.', $filename)); + } + } + } + + return $translatorBag; + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $catalogue = $translatorBag->getCatalogue($this->defaultLocale); + + if (!$catalogue) { + $catalogue = $translatorBag->getCatalogues()[0]; + } + + $keysIds = []; + foreach ($catalogue->all() as $messagesByDomains) { + foreach ($messagesByDomains as $domain => $messages) { + $keysToDelete = []; + foreach ($messages as $message) { + $keysToDelete[] = $message; + } + $keysIds += $this->getKeysIds($keysToDelete, $domain); + } + } + + $response = $this->client->request('DELETE', sprintf('/projects/%s/keys', $this->projectId), [ + 'json' => ['keys' => $keysIds], + ]); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to delete keys from Lokalise: "%s".', $response->getContent(false)), $response); + } + } + + /** + * 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 + */ + private function createKeysWithTranslations(TranslatorBag $translatorBag): void + { + $keys = []; + $catalogue = $translatorBag->getCatalogue($this->defaultLocale); + + if (!$catalogue) { + $catalogue = $translatorBag->getCatalogues()[0]; + } + + foreach ($translatorBag->getDomains() as $domain) { + foreach ($catalogue->all($domain) as $key => $message) { + $keys[] = [ + 'key_name' => $key, + 'platforms' => ['web'], + 'filenames' => [ + 'web' => $this->generateLokaliseFilenameFromDomain($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, + ], + 'translations' => array_map(function ($catalogue) use ($key, $domain) { + return [ + 'language_iso' => $catalogue->getLocale(), + 'translation' => $catalogue->get($key, $domain), + ]; + }, $translatorBag->getCatalogues()), + ]; + } + } + + $chunks = array_chunk($keys, 500); + + foreach ($chunks as $chunk) { + $response = $this->client->request('POST', sprintf('/projects/%s/keys', $this->projectId), [ + 'json' => ['keys' => $chunk], + ]); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to add keys and translations to 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', sprintf('/projects/%s/files/export', $this->projectId), [ + 'json' => [ + 'format' => 'symfony_xliff', + 'original_filenames' => true, + 'directory_prefix' => '%LANG_ISO%', + 'filter_langs' => array_values($locales), + 'filter_filenames' => array_map([$this, 'generateLokaliseFilenameFromDomain'], $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 getKeysIds(array $keys, string $domain): array + { + $response = $this->client->request('GET', sprintf('/projects/%s/keys', $this->projectId), [ + 'query' => [ + 'filter_keys' => $keys, + 'filter_filenames' => $this->generateLokaliseFilenameFromDomain($domain), + ], + ]); + + $responseContent = $response->toArray(false); + + if (200 !== $response->getStatusCode()) { + throw new ProviderException(sprintf('Unable to get keys ids from Lokalise: "%s".', $response->getContent(false)), $response); + } + + return array_reduce($responseContent['keys'], function ($keysIds, array $keyItem) { + $keysIds[] = $keyItem['key_id']; + + return $keysIds; + }, []); + } + + private function generateLokaliseFilenameFromDomain(string $domain): string + { + return sprintf('%s.xliff', $domain); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/Provider/LokaliseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Lokalise/Provider/LokaliseProviderFactory.php new file mode 100644 index 0000000000000..e449e2feb05a3 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Provider/LokaliseProviderFactory.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Lokalise\Provider; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + * + * @experimental in 5.3 + */ +final class LokaliseProviderFactory extends AbstractProviderFactory +{ + public const SCHEME = 'lokalise'; + private const HOST = 'api.lokalise.com/api2/'; + + 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 (self::SCHEME === $dsn->getScheme()) { + $endpoint = sprintf('%s%s', 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(), $dsn->getPort() ? ':' . $dsn->getPort() : ''); + $client = $this->client->withOptions([ + 'base_uri' => 'https://' . $endpoint, + 'headers' => [ + 'X-Api-Token' => $this->getPassword($dsn), + ], + ]); + + return new LokaliseProvider($this->getUser($dsn), $client, $this->loader, $this->logger, $this->defaultLocale, $endpoint); + } + + throw new UnsupportedSchemeException($dsn, self::SCHEME, $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return [self::SCHEME]; + } +} 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 0000000000000..063996363eb89 --- /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 0000000000000..3f23dd8d98d25 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/Tests/LokaliseProviderFactoryTest.php @@ -0,0 +1,39 @@ +getClient(), $this->getLogger(), $this->getDefaultLocale(), $this->getLoader(), $this->getXliffFileDumper()); + } +} 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 0000000000000..de7b469c4ab4e --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/composer.json @@ -0,0 +1,30 @@ +{ + "name": "symfony/lokalise-translation", + "type": "symfony-bridge", + "description": "Symfony Lokalise Translation 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" + }, + "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 0000000000000..dbcb5c84439b8 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Lokalise/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + +