PageRenderTime 64ms CodeModel.GetById 29ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Symfony/Component/Translation/Bridge/Lokalise/LokaliseProvider.php

https://github.com/FabienD/symfony
PHP | 357 lines | 257 code | 66 blank | 34 comment | 19 complexity | 2d4801e0e0e89571cd752b43d81debb3 MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Translation\Bridge\Lokalise;
  11. use Psr\Log\LoggerInterface;
  12. use Symfony\Component\Translation\Exception\ProviderException;
  13. use Symfony\Component\Translation\Loader\LoaderInterface;
  14. use Symfony\Component\Translation\MessageCatalogueInterface;
  15. use Symfony\Component\Translation\Provider\ProviderInterface;
  16. use Symfony\Component\Translation\TranslatorBag;
  17. use Symfony\Component\Translation\TranslatorBagInterface;
  18. use Symfony\Contracts\HttpClient\HttpClientInterface;
  19. /**
  20. * @author Mathieu Santostefano <msantostefano@protonmail.com>
  21. *
  22. * In Lokalise:
  23. * * Filenames refers to Symfony's translation domains;
  24. * * Keys refers to Symfony's translation keys;
  25. * * Translations refers to Symfony's translated messages
  26. */
  27. final class LokaliseProvider implements ProviderInterface
  28. {
  29. private const LOKALISE_GET_KEYS_LIMIT = 5000;
  30. private HttpClientInterface $client;
  31. private LoaderInterface $loader;
  32. private LoggerInterface $logger;
  33. private string $defaultLocale;
  34. private string $endpoint;
  35. public function __construct(HttpClientInterface $client, LoaderInterface $loader, LoggerInterface $logger, string $defaultLocale, string $endpoint)
  36. {
  37. $this->client = $client;
  38. $this->loader = $loader;
  39. $this->logger = $logger;
  40. $this->defaultLocale = $defaultLocale;
  41. $this->endpoint = $endpoint;
  42. }
  43. public function __toString(): string
  44. {
  45. return sprintf('lokalise://%s', $this->endpoint);
  46. }
  47. /**
  48. * {@inheritdoc}
  49. *
  50. * Lokalise API recommends sending payload in chunks of up to 500 keys per request.
  51. *
  52. * @see https://app.lokalise.com/api2docs/curl/#transition-create-keys-post
  53. */
  54. public function write(TranslatorBagInterface $translatorBag): void
  55. {
  56. $defaultCatalogue = $translatorBag->getCatalogue($this->defaultLocale);
  57. if (!$defaultCatalogue) {
  58. $defaultCatalogue = $translatorBag->getCatalogues()[0];
  59. }
  60. $this->ensureAllLocalesAreCreated($translatorBag);
  61. $existingKeysByDomain = [];
  62. foreach ($defaultCatalogue->getDomains() as $domain) {
  63. if (!\array_key_exists($domain, $existingKeysByDomain)) {
  64. $existingKeysByDomain[$domain] = [];
  65. }
  66. $existingKeysByDomain[$domain] += $this->getKeysIds([], $domain);
  67. }
  68. $keysToCreate = $createdKeysByDomain = [];
  69. foreach ($existingKeysByDomain as $domain => $existingKeys) {
  70. $allKeysForDomain = array_keys($defaultCatalogue->all($domain));
  71. foreach (array_keys($existingKeys) as $keyName) {
  72. unset($allKeysForDomain[$keyName]);
  73. }
  74. $keysToCreate[$domain] = $allKeysForDomain;
  75. }
  76. foreach ($keysToCreate as $domain => $keys) {
  77. $createdKeysByDomain[$domain] = $this->createKeys($keys, $domain);
  78. }
  79. $this->updateTranslations(array_merge_recursive($createdKeysByDomain, $existingKeysByDomain), $translatorBag);
  80. }
  81. public function read(array $domains, array $locales): TranslatorBag
  82. {
  83. $translatorBag = new TranslatorBag();
  84. $translations = $this->exportFiles($locales, $domains);
  85. foreach ($translations as $locale => $files) {
  86. foreach ($files as $filename => $content) {
  87. $translatorBag->addCatalogue($this->loader->load($content['content'], $locale, str_replace('.xliff', '', $filename)));
  88. }
  89. }
  90. return $translatorBag;
  91. }
  92. public function delete(TranslatorBagInterface $translatorBag): void
  93. {
  94. $catalogue = $translatorBag->getCatalogue($this->defaultLocale);
  95. if (!$catalogue) {
  96. $catalogue = $translatorBag->getCatalogues()[0];
  97. }
  98. $keysIds = [];
  99. foreach ($catalogue->getDomains() as $domain) {
  100. $keysToDelete = [];
  101. foreach (array_keys($catalogue->all($domain)) as $key) {
  102. $keysToDelete[] = $key;
  103. }
  104. $keysIds += $this->getKeysIds($keysToDelete, $domain);
  105. }
  106. $response = $this->client->request('DELETE', 'keys', [
  107. 'json' => ['keys' => array_values($keysIds)],
  108. ]);
  109. if (200 !== $response->getStatusCode()) {
  110. throw new ProviderException(sprintf('Unable to delete keys from Lokalise: "%s".', $response->getContent(false)), $response);
  111. }
  112. }
  113. /**
  114. * @see https://app.lokalise.com/api2docs/curl/#transition-download-files-post
  115. */
  116. private function exportFiles(array $locales, array $domains): array
  117. {
  118. $response = $this->client->request('POST', 'files/export', [
  119. 'json' => [
  120. 'format' => 'symfony_xliff',
  121. 'original_filenames' => true,
  122. 'directory_prefix' => '%LANG_ISO%',
  123. 'filter_langs' => array_values($locales),
  124. 'filter_filenames' => array_map($this->getLokaliseFilenameFromDomain(...), $domains),
  125. 'export_empty_as' => 'skip',
  126. ],
  127. ]);
  128. $responseContent = $response->toArray(false);
  129. if (406 === $response->getStatusCode()
  130. && 'No keys found with specified filenames.' === $responseContent['error']['message']
  131. ) {
  132. return [];
  133. }
  134. if (200 !== $response->getStatusCode()) {
  135. throw new ProviderException(sprintf('Unable to export translations from Lokalise: "%s".', $response->getContent(false)), $response);
  136. }
  137. return $responseContent['files'];
  138. }
  139. private function createKeys(array $keys, string $domain): array
  140. {
  141. $keysToCreate = [];
  142. foreach ($keys as $key) {
  143. $keysToCreate[] = [
  144. 'key_name' => $key,
  145. 'platforms' => ['web'],
  146. 'filenames' => [
  147. 'web' => $this->getLokaliseFilenameFromDomain($domain),
  148. // There is a bug in Lokalise with "Per platform key names" option enabled,
  149. // we need to provide a filename for all platforms.
  150. 'ios' => null,
  151. 'android' => null,
  152. 'other' => null,
  153. ],
  154. ];
  155. }
  156. $chunks = array_chunk($keysToCreate, 500);
  157. $responses = [];
  158. foreach ($chunks as $chunk) {
  159. $responses[] = $this->client->request('POST', 'keys', [
  160. 'json' => ['keys' => $chunk],
  161. ]);
  162. }
  163. $createdKeys = [];
  164. foreach ($responses as $response) {
  165. if (200 !== $response->getStatusCode()) {
  166. $this->logger->error(sprintf('Unable to create keys to Lokalise: "%s".', $response->getContent(false)));
  167. continue;
  168. }
  169. $keys = $response->toArray(false)['keys'] ?? [];
  170. $createdKeys = array_reduce($keys, static function ($carry, array $keyItem) {
  171. $carry[$keyItem['key_name']['web']] = $keyItem['key_id'];
  172. return $carry;
  173. }, $createdKeys);
  174. }
  175. return $createdKeys;
  176. }
  177. /**
  178. * Translations will be created for keys without existing translations.
  179. * Translations will be updated for keys with existing translations.
  180. */
  181. private function updateTranslations(array $keysByDomain, TranslatorBagInterface $translatorBag): void
  182. {
  183. $keysToUpdate = [];
  184. foreach ($keysByDomain as $domain => $keys) {
  185. foreach ($keys as $keyName => $keyId) {
  186. $keysToUpdate[] = [
  187. 'key_id' => $keyId,
  188. 'platforms' => ['web'],
  189. 'filenames' => [
  190. 'web' => $this->getLokaliseFilenameFromDomain($domain),
  191. 'ios' => null,
  192. 'android' => null,
  193. 'other' => null,
  194. ],
  195. 'translations' => array_reduce($translatorBag->getCatalogues(), static function ($carry, MessageCatalogueInterface $catalogue) use ($keyName, $domain) {
  196. // Message could be not found because the catalogue is empty.
  197. // We must not send the key in place of the message to avoid wrong message update on the provider.
  198. if ($catalogue->get($keyName, $domain) !== $keyName) {
  199. $carry[] = [
  200. 'language_iso' => $catalogue->getLocale(),
  201. 'translation' => $catalogue->get($keyName, $domain),
  202. ];
  203. }
  204. return $carry;
  205. }, []),
  206. ];
  207. }
  208. }
  209. $response = $this->client->request('PUT', 'keys', [
  210. 'json' => ['keys' => $keysToUpdate],
  211. ]);
  212. if (200 !== $response->getStatusCode()) {
  213. $this->logger->error(sprintf('Unable to create/update translations to Lokalise: "%s".', $response->getContent(false)));
  214. }
  215. }
  216. private function getKeysIds(array $keys, string $domain, int $page = 1): array
  217. {
  218. $response = $this->client->request('GET', 'keys', [
  219. 'query' => [
  220. 'filter_keys' => implode(',', $keys),
  221. 'filter_filenames' => $this->getLokaliseFilenameFromDomain($domain),
  222. 'limit' => self::LOKALISE_GET_KEYS_LIMIT,
  223. 'page' => $page,
  224. ],
  225. ]);
  226. if (200 !== $response->getStatusCode()) {
  227. $this->logger->error(sprintf('Unable to get keys ids from Lokalise: "%s".', $response->getContent(false)));
  228. }
  229. $result = [];
  230. $keysFromResponse = $response->toArray(false)['keys'] ?? [];
  231. if (\count($keysFromResponse) > 0) {
  232. $result = array_reduce($keysFromResponse, static function ($carry, array $keyItem) {
  233. $carry[$keyItem['key_name']['web']] = $keyItem['key_id'];
  234. return $carry;
  235. }, []);
  236. }
  237. $paginationTotalCount = $response->getHeaders(false)['x-pagination-total-count'] ?? [];
  238. $keysTotalCount = (int) (reset($paginationTotalCount) ?? 0);
  239. if (0 === $keysTotalCount) {
  240. return $result;
  241. }
  242. $pages = ceil($keysTotalCount / self::LOKALISE_GET_KEYS_LIMIT);
  243. if ($page < $pages) {
  244. $result = array_merge($result, $this->getKeysIds($keys, $domain, ++$page));
  245. }
  246. return $result;
  247. }
  248. private function ensureAllLocalesAreCreated(TranslatorBagInterface $translatorBag): void
  249. {
  250. $providerLanguages = $this->getLanguages();
  251. $missingLanguages = array_reduce($translatorBag->getCatalogues(), static function ($carry, $catalogue) use ($providerLanguages) {
  252. if (!\in_array($catalogue->getLocale(), $providerLanguages, true)) {
  253. $carry[] = $catalogue->getLocale();
  254. }
  255. return $carry;
  256. }, []);
  257. if ($missingLanguages) {
  258. $this->createLanguages($missingLanguages);
  259. }
  260. }
  261. private function getLanguages(): array
  262. {
  263. $response = $this->client->request('GET', 'languages');
  264. if (200 !== $response->getStatusCode()) {
  265. $this->logger->error(sprintf('Unable to get languages from Lokalise: "%s".', $response->getContent(false)));
  266. return [];
  267. }
  268. $responseContent = $response->toArray(false);
  269. if (\array_key_exists('languages', $responseContent)) {
  270. return array_column($responseContent['languages'], 'lang_iso');
  271. }
  272. return [];
  273. }
  274. private function createLanguages(array $languages): void
  275. {
  276. $response = $this->client->request('POST', 'languages', [
  277. 'json' => [
  278. 'languages' => array_map(static function ($language) {
  279. return ['lang_iso' => $language];
  280. }, $languages),
  281. ],
  282. ]);
  283. if (200 !== $response->getStatusCode()) {
  284. $this->logger->error(sprintf('Unable to create languages on Lokalise: "%s".', $response->getContent(false)));
  285. }
  286. }
  287. private function getLokaliseFilenameFromDomain(string $domain): string
  288. {
  289. return sprintf('%s.xliff', $domain);
  290. }
  291. }