1212namespace Symfony \Component \Translation \Bridge \Lokalise ;
1313
1414use Psr \Log \LoggerInterface ;
15+ use Symfony \Component \Translation \Exception \LogicException ;
1516use Symfony \Component \Translation \Exception \ProviderException ;
1617use Symfony \Component \Translation \Loader \LoaderInterface ;
1718use Symfony \Component \Translation \MessageCatalogueInterface ;
3132final class LokaliseProvider implements ProviderInterface
3233{
3334 private const LOKALISE_GET_KEYS_LIMIT = 5000 ;
35+ private const PROJECT_TOO_BIG_STATUS_CODE = 413 ;
36+ /** @var list<string> */
37+ private const FAILED_PROCESS_STATUS = ['cancelled ' , 'failed ' ];
38+ private const SUCESS_PROCESS_STATUS = 'finished ' ;
3439
3540 private HttpClientInterface $ client ;
3641 private LoaderInterface $ loader ;
@@ -165,6 +170,13 @@ private function exportFiles(array $locales, array $domains): array
165170 }
166171
167172 if (200 !== $ response ->getStatusCode ()) {
173+ if (($ errorCode = $ responseContent ['error ' ]['code ' ] ?? null ) && self ::PROJECT_TOO_BIG_STATUS_CODE === $ errorCode ) {
174+ if (!\extension_loaded ('zip ' )) {
175+ throw new ProviderException (\sprintf ('Unable to export translations from Lokalise: "%s". There are too many translations. You need to enable the "zip" extension to use asynchronous export. ' , $ response ->getContent (false )), $ response );
176+ }
177+
178+ return $ this ->exportFilesAsync ($ locales , $ domains );
179+ }
168180 throw new ProviderException (\sprintf ('Unable to export translations from Lokalise: "%s". ' , $ response ->getContent (false )), $ response );
169181 }
170182
@@ -176,6 +188,109 @@ private function exportFiles(array $locales, array $domains): array
176188 return array_combine ($ reformattedLanguages , $ responseContent ['files ' ]);
177189 }
178190
191+ /**
192+ * @see https://developers.lokalise.com/reference/download-files-async
193+ */
194+ private function exportFilesAsync (array $ locales , array $ domains ): array
195+ {
196+ $ response = $ this ->client ->request ('POST ' , 'files/async-download ' , [
197+ 'json ' => [
198+ 'format ' => 'symfony_xliff ' ,
199+ 'original_filenames ' => true ,
200+ 'filter_langs ' => array_values ($ locales ),
201+ 'filter_filenames ' => array_map ($ this ->getLokaliseFilenameFromDomain (...), $ domains ),
202+ 'export_empty_as ' => 'skip ' ,
203+ 'replace_breaks ' => false ,
204+ ],
205+ ]);
206+
207+ if (200 !== $ response ->getStatusCode ()) {
208+ throw new ProviderException (\sprintf ('Unable to export translations from Lokalise: "%s". ' , $ response ->getContent (false )), $ response );
209+ }
210+
211+ $ processId = $ response ->toArray ()['process_id ' ];
212+ $ attempt = 0 ;
213+ while (true ) {
214+ $ response = $ this ->client ->request ('GET ' , \sprintf ('processes/%s ' , $ processId ));
215+ $ process = $ response ->toArray ()['process ' ];
216+ if (\in_array ($ process ['status ' ], self ::FAILED_PROCESS_STATUS , true )) {
217+ throw new ProviderException (\sprintf ('Unable to export translations from Lokalise: "%s". ' , $ response ->getContent (false )), $ response );
218+ }
219+ if (self ::SUCESS_PROCESS_STATUS === $ process ['status ' ]) {
220+ $ downloadUrl = $ process ['details ' ]['download_url ' ];
221+ break ;
222+ }
223+ ++$ attempt ;
224+ usleep (500000 * $ attempt );
225+ }
226+
227+ $ newfile = \sprintf ('%s%s%s.zip ' , sys_get_temp_dir (), \DIRECTORY_SEPARATOR , uniqid ());
228+ $ extractPath = \sprintf ('%s%s%s ' , sys_get_temp_dir (), \DIRECTORY_SEPARATOR , uniqid ());
229+ if (!copy ($ downloadUrl , $ newfile )) {
230+ throw new LogicException (\sprintf ('failed to copy "%s". ' , $ downloadUrl ));
231+ }
232+
233+ try {
234+ $ zip = new \ZipArchive ();
235+ if (!$ zip ->open ($ newfile , \ZipArchive::CREATE )) {
236+ throw new LogicException (\sprintf ('failed to open zip file "%s". ' , $ newfile ));
237+ }
238+
239+ try {
240+ if (!$ zip ->extractTo ($ extractPath )) {
241+ throw new LogicException (\sprintf ('failed to extract content from zip file "%s". ' , $ newfile ));
242+ }
243+ } finally {
244+ $ zip ->close ();
245+ }
246+
247+ $ fileContents = $ this ->getFileContents ($ extractPath );
248+
249+ // Lokalise returns languages with "-" separator, we need to reformat them to "_" separator.
250+ /** @var array<array-key, array-key> $reformattedLanguages */
251+ $ reformattedLanguages = array_map (function ($ language ) {
252+ return str_replace ('- ' , '_ ' , $ language );
253+ }, array_keys ($ fileContents ));
254+
255+ return array_combine ($ reformattedLanguages , $ fileContents );
256+ } finally {
257+ unlink ($ newfile );
258+ $ this ->removeDir ($ extractPath );
259+ }
260+ }
261+
262+ private function getFileContents (string $ dir , string $ baseDir = '' ): array
263+ {
264+ $ fileContents = [];
265+ foreach (scandir ($ dir ) as $ filename ) {
266+ if (\in_array ($ filename , ['. ' , '.. ' ])) {
267+ continue ;
268+ }
269+ $ path = \sprintf ('%s%s%s ' , $ dir , \DIRECTORY_SEPARATOR , $ filename );
270+ if (is_dir ($ path )) {
271+ $ fileContents = array_merge ($ fileContents , $ this ->getFileContents ($ path , $ filename ));
272+ continue ;
273+ }
274+ $ fileContents [$ baseDir ][$ filename ]['content ' ] = file_get_contents ($ path );
275+ }
276+
277+ return $ fileContents ;
278+ }
279+
280+ private function removeDir (string $ dir ): void
281+ {
282+ $ it = new \RecursiveDirectoryIterator ($ dir , \RecursiveDirectoryIterator::SKIP_DOTS );
283+ $ files = new \RecursiveIteratorIterator ($ it , \RecursiveIteratorIterator::CHILD_FIRST );
284+ foreach ($ files as $ file ) {
285+ if ($ file ->isDir ()) {
286+ rmdir ($ file ->getPathname ());
287+ } else {
288+ unlink ($ file ->getPathname ());
289+ }
290+ }
291+ rmdir ($ dir );
292+ }
293+
179294 private function createKeys (array $ keys , string $ domain ): array
180295 {
181296 $ keysToCreate = [];
0 commit comments