diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ecd23c..4a580095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] -## [1.4.7 - 2024-01-xx] +## [1.5.0 - 2024-01-xx] ### Added - Added filesplugin batch actions implementation. #203 +- Added MachineTranslation providers API. #210 +### Changed + +- Changed TextProcessing providers API flow to asynchronous. #208 +- Changed SpeechToText providers API flow to asynchronous. #209 ## [1.4.6 - 2024-01-05] diff --git a/appinfo/routes.php b/appinfo/routes.php index e35e8315..5676e8c0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -112,5 +112,11 @@ ['name' => 'textProcessing#unregisterProvider', 'url' => '/api/v1/ai_provider/text_processing', 'verb' => 'DELETE'], ['name' => 'textProcessing#getProvider', 'url' => '/api/v1/ai_provider/text_processing', 'verb' => 'GET'], ['name' => 'textProcessing#reportResult', 'url' => '/api/v1/ai_provider/text_processing', 'verb' => 'PUT'], + + // Machine-Translation + ['name' => 'Translation#registerProvider', 'url' => '/api/v1/ai_provider/translation', 'verb' => 'POST'], + ['name' => 'Translation#unregisterProvider', 'url' => '/api/v1/ai_provider/translation', 'verb' => 'DELETE'], + ['name' => 'Translation#getProvider', 'url' => '/api/v1/ai_provider/translation', 'verb' => 'GET'], + ['name' => 'Translation#reportResult', 'url' => '/api/v1/ai_provider/translation', 'verb' => 'PUT'], ], ]; diff --git a/docs/tech_details/api/index.rst b/docs/tech_details/api/index.rst index 6140f541..55dc577e 100644 --- a/docs/tech_details/api/index.rst +++ b/docs/tech_details/api/index.rst @@ -20,4 +20,5 @@ AppAPI Nextcloud APIs talkbots speechtotext textprocessing + machinetranslation other_ocs diff --git a/docs/tech_details/api/machinetranslation.rst b/docs/tech_details/api/machinetranslation.rst new file mode 100644 index 00000000..150f88e3 --- /dev/null +++ b/docs/tech_details/api/machinetranslation.rst @@ -0,0 +1,79 @@ +=================== +Machine Translation +=================== + +AppAPI provides a Machine-Translation providers registration mechanism for ExApps. + +.. note:: + + Available since Nextcloud 29. + +Registering translation provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``POST /apps/app_api/api/v1/ai_provider/translation`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + "display_name": "Provider Display Name", + "action_handler": "/handler_route_on_ex_app", + "from_languages": { + "en": "English", + "fr": "French", + }, + "to_languages": { + "en": "English", + "fr": "French", + }, + } + +.. note:: + + ``from_languages`` and ``to_languages`` are JSON object with language code as key and language name as value. + + +Response +******** + +On successful registration response with status code 200 is returned. + +Unregistering translation provider (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``DELETE /apps/app_api/api/v1/ai_provider/translation`` + +Request data +************ + +.. code-block:: json + + { + "name": "unique_provider_name", + } + +Response +******** + +On successful unregister response with status code 200 is returned. + + +Report translation result (OCS) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OCS endpoint: ``PUT /apps/app_api/api/v1/ai_provider/translation`` + +Request data +************ + +.. code-block:: json + + { + "task_id": "queued_task_id", + "result": "translated_text", + "error": "error_message_if_any", + } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5277cd61..d85591a3 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,6 +17,7 @@ use OCA\AppAPI\PublicCapabilities; use OCA\AppAPI\Service\ProvidersAI\SpeechToTextService; use OCA\AppAPI\Service\ProvidersAI\TextProcessingService; +use OCA\AppAPI\Service\ProvidersAI\TranslationService; use OCA\AppAPI\Service\UI\TopMenuService; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent; @@ -71,6 +72,10 @@ public function register(IRegistrationContext $context): void { /** @var TextProcessingService $textProcessingService */ $textProcessingService = $container->get(TextProcessingService::class); $textProcessingService->registerExAppTextProcessingProviders($context, $container->getServer()); + + /** @var TranslationService $translationService */ + $translationService = $container->get(TranslationService::class); + $translationService->registerExAppTranslationProviders($context, $container->getServer()); } catch (NotFoundExceptionInterface|ContainerExceptionInterface) { } } diff --git a/lib/BackgroundJob/ProvidersAICleanUpJob.php b/lib/BackgroundJob/ProvidersAICleanUpJob.php index e8f912f8..7df3d131 100644 --- a/lib/BackgroundJob/ProvidersAICleanUpJob.php +++ b/lib/BackgroundJob/ProvidersAICleanUpJob.php @@ -6,6 +6,7 @@ use OCA\AppAPI\Db\SpeechToText\SpeechToTextProviderQueueMapper; use OCA\AppAPI\Db\TextProcessing\TextProcessingProviderQueueMapper; +use OCA\AppAPI\Db\Translation\TranslationQueueMapper; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; @@ -16,9 +17,10 @@ class ProvidersAICleanUpJob extends TimedJob { private const overdueTime = 7 * 24 * 60 * 60; public function __construct( - ITimeFactory $time, + ITimeFactory $time, private readonly TextProcessingProviderQueueMapper $mapperTextProcessing, private readonly SpeechToTextProviderQueueMapper $mapperSpeechToText, + private readonly TranslationQueueMapper $mapperTranslation, ) { parent::__construct($time); @@ -32,6 +34,7 @@ protected function run($argument): void { try { $this->mapperTextProcessing->removeAllOlderThenThat(self::overdueTime); $this->mapperSpeechToText->removeAllOlderThenThat(self::overdueTime); + $this->mapperTranslation->removeAllOlderThenThat(self::overdueTime); } catch (Exception) { } } diff --git a/lib/Controller/TranslationController.php b/lib/Controller/TranslationController.php new file mode 100644 index 00000000..c80ee1f1 --- /dev/null +++ b/lib/Controller/TranslationController.php @@ -0,0 +1,126 @@ +request = $request; + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function registerProvider( + string $name, + string $displayName, + array $fromLanguages, + array $toLanguages, + string $actionHandler, + ): DataResponse { + $ncVersion = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($ncVersion, '29.0', '<')) { + return new DataResponse([], Http::STATUS_NOT_IMPLEMENTED); + } + + $provider = $this->translationService->registerTranslationProvider( + $this->request->getHeader('EX-APP-ID'), $name, $displayName, + $fromLanguages, $toLanguages, $actionHandler + ); + + if ($provider === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(); + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function unregisterProvider(string $name): Response { + $ncVersion = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($ncVersion, '29.0', '<')) { + return new DataResponse([], Http::STATUS_NOT_IMPLEMENTED); + } + + $unregistered = $this->translationService->unregisterTranslationProvider( + $this->request->getHeader('EX-APP-ID'), $name + ); + + if ($unregistered === null) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + return new DataResponse(); + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function getProvider(string $name): DataResponse { + $ncVersion = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($ncVersion, '29.0', '<')) { + return new DataResponse([], Http::STATUS_NOT_IMPLEMENTED); + } + $result = $this->translationService->getExAppTranslationProvider( + $this->request->getHeader('EX-APP-ID'), $name + ); + if (!$result) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + return new DataResponse($result, Http::STATUS_OK); + } + + #[NoCSRFRequired] + #[PublicPage] + #[AppAPIAuth] + public function reportResult(int $taskId, string $result, string $error = ""): DataResponse { + $ncVersion = $this->config->getSystemValueString('version', '0.0.0'); + if (version_compare($ncVersion, '29.0', '<')) { + return new DataResponse([], Http::STATUS_NOT_IMPLEMENTED); + } + try { + $taskResult = $this->mapper->getById($taskId); + } catch (DoesNotExistException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } catch (MultipleObjectsReturnedException | Exception) { + return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $taskResult->setResult($result); + $taskResult->setError($error); + $taskResult->setFinished(1); + try { + $this->mapper->update($taskResult); + } catch (Exception) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + return new DataResponse([], Http::STATUS_OK); + } +} diff --git a/lib/Db/Translation/TranslationProvider.php b/lib/Db/Translation/TranslationProvider.php new file mode 100644 index 00000000..d7ecc2a2 --- /dev/null +++ b/lib/Db/Translation/TranslationProvider.php @@ -0,0 +1,77 @@ +addType('appid', 'string'); + $this->addType('name', 'string'); + $this->addType('displayName', 'string'); + $this->addType('fromLanguages', 'json'); + $this->addType('toLanguages', 'json'); + $this->addType('actionHandler', 'string'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['appid'])) { + $this->setAppid($params['appid']); + } + if (isset($params['name'])) { + $this->setName($params['name']); + } + if (isset($params['display_name'])) { + $this->setDisplayName($params['display_name']); + } + if (isset($params['from_languages'])) { + $this->setFromLanguages($params['from_languages']); + } + if (isset($params['to_languages'])) { + $this->setToLanguages($params['to_languages']); + } + if (isset($params['action_handler'])) { + $this->setActionHandler($params['action_handler']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'appid' => $this->getAppid(), + 'name' => $this->getName(), + 'display_name' => $this->getDisplayName(), + 'from_languages' => $this->getFromLanguages(), + 'to_languages' => $this->getToLanguages(), + 'action_handler' => $this->getActionHandler(), + ]; + } +} diff --git a/lib/Db/Translation/TranslationProviderMapper.php b/lib/Db/Translation/TranslationProviderMapper.php new file mode 100644 index 00000000..2469c92c --- /dev/null +++ b/lib/Db/Translation/TranslationProviderMapper.php @@ -0,0 +1,73 @@ + + */ +class TranslationProviderMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_translation'); + } + + /** + * @throws Exception + */ + public function findAllEnabled(): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select( + 'ex_translation.appid', + 'ex_translation.name', + 'ex_translation.display_name', + 'ex_translation.from_languages', + 'ex_translation.to_languages', + 'ex_translation.action_handler', + ) + ->from($this->tableName, 'ex_translation') + ->innerJoin('ex_translation', 'ex_apps', 'exa', 'exa.appid = ex_translation.appid') + ->where( + $qb->expr()->eq('exa.enabled', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT)) + ) + ->executeQuery(); + return $result->fetchAll(); + } + + /** + * @param string $appId + * @param string $name + * + * @throws Exception + * @throws MultipleObjectsReturnedException + * + * @throws DoesNotExistException + */ + public function findByAppidName(string $appId, string $name): TranslationProvider { + $qb = $this->db->getQueryBuilder(); + return $this->findEntity($qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('appid', $qb->createNamedParameter($appId), IQueryBuilder::PARAM_STR)) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter($name), IQueryBuilder::PARAM_STR)) + ); + } + + /** + * @throws Exception + */ + public function removeAllByAppId(string $appId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->eq('appid', $qb->createNamedParameter($appId, IQueryBuilder::PARAM_STR)) + ); + return $qb->executeStatement(); + } +} diff --git a/lib/Db/Translation/TranslationQueue.php b/lib/Db/Translation/TranslationQueue.php new file mode 100644 index 00000000..ca19f2ea --- /dev/null +++ b/lib/Db/Translation/TranslationQueue.php @@ -0,0 +1,61 @@ +addType('result', 'string'); + $this->addType('error', 'string'); + $this->addType('finished', 'int'); + $this->addType('createdTime', 'int'); + + if (isset($params['id'])) { + $this->setId($params['id']); + } + if (isset($params['result'])) { + $this->setResult($params['result']); + } + if (isset($params['error'])) { + $this->setError($params['error']); + } + if (isset($params['finished'])) { + $this->setFinished($params['finished']); + } + if (isset($params['created_time'])) { + $this->setCreatedTime($params['created_time']); + } + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'result' => $this->getResult(), + 'error' => $this->getError(), + 'finished' => $this->getFinished(), + 'created_time' => $this->getCreatedTime(), + ]; + } +} diff --git a/lib/Db/Translation/TranslationQueueMapper.php b/lib/Db/Translation/TranslationQueueMapper.php new file mode 100644 index 00000000..9deb2747 --- /dev/null +++ b/lib/Db/Translation/TranslationQueueMapper.php @@ -0,0 +1,52 @@ + + */ +class TranslationQueueMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ex_translation_q'); + } + + /** + * @param int $id + * + * @throws DoesNotExistException if not found + * @throws MultipleObjectsReturnedException if more than one result + * @throws Exception + * + * @return TranslationQueue + */ + public function getById(int $id): TranslationQueue { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where( + $qb->expr()->eq('id', $qb->createNamedParameter($id)) + ); + return $this->findEntity($qb); + } + + /** + * @throws Exception + */ + public function removeAllOlderThenThat(int $overdueTime): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->tableName) + ->where( + $qb->expr()->gte($qb->createNamedParameter(time() - $overdueTime, IQueryBuilder::PARAM_INT), 'created_time') + ); + return $qb->executeStatement(); + } +} diff --git a/lib/Migration/Version1008Date202401121205.php b/lib/Migration/Version1008Date202401121205.php new file mode 100644 index 00000000..fd8ee2d0 --- /dev/null +++ b/lib/Migration/Version1008Date202401121205.php @@ -0,0 +1,90 @@ +hasTable('ex_translation')) { + $table = $schema->createTable('ex_translation'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('appid', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('from_languages', Types::JSON, [ + 'notnull' => true, + ]); + $table->addColumn('to_languages', Types::JSON, [ + 'notnull' => true, + ]); + $table->addColumn('action_handler', Types::STRING, [ + 'notnull' => true, + 'length' => 410, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['appid', 'name'], 'ex_translation__idx'); + } + + if (!$schema->hasTable('ex_translation_q')) { + $table = $schema->createTable('ex_translation_q'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('result', Types::TEXT, [ + 'notnull' => true, + 'default' => '', + ]); + $table->addColumn('error', Types::STRING, [ + 'notnull' => true, + 'default' => '', + 'length' => 1024, + ]); + $table->addColumn('finished', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('created_time', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['finished']); + } + + return $schema; + } +} diff --git a/lib/Service/AppAPIService.php b/lib/Service/AppAPIService.php index b0bf2fe5..136a22b7 100644 --- a/lib/Service/AppAPIService.php +++ b/lib/Service/AppAPIService.php @@ -12,6 +12,7 @@ use OCA\AppAPI\Notifications\ExNotificationsManager; use OCA\AppAPI\Service\ProvidersAI\SpeechToTextService; use OCA\AppAPI\Service\ProvidersAI\TextProcessingService; +use OCA\AppAPI\Service\ProvidersAI\TranslationService; use OCA\AppAPI\Service\UI\FilesActionsMenuService; use OCA\AppAPI\Service\UI\InitialStateService; use OCA\AppAPI\Service\UI\ScriptsService; @@ -62,8 +63,9 @@ public function __construct( private readonly ScriptsService $scriptsService, private readonly StylesService $stylesService, private readonly FilesActionsMenuService $filesActionsMenuService, - private readonly SpeechToTextService $speechToTextService, - private readonly TextProcessingService $textProcessingService, + private readonly SpeechToTextService $speechToTextService, + private readonly TextProcessingService $textProcessingService, + private readonly TranslationService $translationService, private readonly ISecureRandom $random, private readonly IUserSession $userSession, private readonly ISession $session, @@ -154,6 +156,7 @@ public function unregisterExApp(string $appId): ?ExApp { $this->stylesService->deleteExAppStyles($appId); $this->speechToTextService->unregisterExAppSpeechToTextProviders($appId); $this->textProcessingService->unregisterExAppTextProcessingProviders($appId); + $this->translationService->unregisterExAppTranslationProviders($appId); $this->cache->remove('/exApp_' . $appId); return $exApp; } catch (Exception $e) { diff --git a/lib/Service/ProvidersAI/TranslationService.php b/lib/Service/ProvidersAI/TranslationService.php new file mode 100644 index 00000000..a5cfb257 --- /dev/null +++ b/lib/Service/ProvidersAI/TranslationService.php @@ -0,0 +1,254 @@ +cache = $cacheFactory->createDistributed(Application::APP_ID . '/ex_translation_providers'); + } + + public function registerTranslationProvider( + string $appId, + string $name, + string $displayName, + array $fromLanguages, + array $toLanguages, + string $actionHandler + ): ?TranslationProvider { + try { + $translationProvider = $this->mapper->findByAppidName($appId, $name); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception) { + $translationProvider = null; + } + try { + $newTranslationProvider = new TranslationProvider([ + 'appid' => $appId, + 'name' => $name, + 'display_name' => $displayName, + 'from_languages' => $fromLanguages, + 'to_languages' => $toLanguages, + 'action_handler' => ltrim($actionHandler, '/'), + ]); + if ($translationProvider !== null) { + $newTranslationProvider->setId($translationProvider->getId()); + } + $translationProvider = $this->mapper->insertOrUpdate($newTranslationProvider); + $this->cache->set('/ex_translation_providers_' . $appId . '_' . $name, $translationProvider); + $this->resetCacheEnabled(); + } catch (Exception $e) { + $this->logger->error( + sprintf('Failed to register ExApp %s TranslationProvider %s. Error: %s', $appId, $name, $e->getMessage()), ['exception' => $e] + ); + return null; + } + return $translationProvider; + } + + public function unregisterTranslationProvider(string $appId, string $name): ?TranslationProvider { + try { + $TranslationProvider = $this->getExAppTranslationProvider($appId, $name); + if ($TranslationProvider === null) { + return null; + } + $this->mapper->delete($TranslationProvider); + $this->cache->remove('/ex_translation_providers_' . $appId . '_' . $name); + $this->resetCacheEnabled(); + return $TranslationProvider; + } catch (Exception $e) { + $this->logger->error(sprintf('Failed to unregister ExApp %s TranslationProvider %s. Error: %s', $appId, $name, $e->getMessage()), ['exception' => $e]); + return null; + } + } + + /** + * Get list of registered Translation providers (only for enabled ExApps) + * + * @return TranslationProvider[] + */ + public function getRegisteredTranslationProviders(): array { + try { + $cacheKey = '/ex_translation_providers'; + $records = $this->cache->get($cacheKey); + if ($records === null) { + $records = $this->mapper->findAllEnabled(); + $this->cache->set($cacheKey, $records); + } + return array_map(function ($record) { + return new TranslationProvider($record); + }, $records); + } catch (Exception) { + return []; + } + } + + public function getExAppTranslationProvider(string $appId, string $name): ?TranslationProvider { + $cacheKey = '/ex_translation_providers_' . $appId . '_' . $name; + $cache = $this->cache->get($cacheKey); + if ($cache !== null) { + return $cache instanceof TranslationProvider ? $cache : new TranslationProvider($cache); + } + + try { + $TranslationProvider = $this->mapper->findByAppIdName($appId, $name); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception) { + return null; + } + $this->cache->set($cacheKey, $TranslationProvider); + return $TranslationProvider; + } + + public function unregisterExAppTranslationProviders(string $appId): int { + try { + $result = $this->mapper->removeAllByAppId($appId); + } catch (Exception) { + $result = -1; + } + $this->cache->clear('/ex_translation_providers_' . $appId); + $this->resetCacheEnabled(); + return $result; + } + + public function resetCacheEnabled(): void { + $this->cache->remove('/ex_translation_providers'); + } + + /** + * Register ExApp anonymous providers implementations of ITranslationProviderWithId and ITranslationProviderWithUserId + * so that they can be used as regular providers in DI container. + */ + public function registerExAppTranslationProviders(IRegistrationContext &$context, IServerContainer $serverContainer): void { + $exAppsProviders = $this->getRegisteredTranslationProviders(); + foreach ($exAppsProviders as $exAppProvider) { + $class = '\\OCA\\AppAPI\\' . $exAppProvider->getAppid() . '\\' . $exAppProvider->getName(); + $provider = $this->getAnonymousExAppProvider($exAppProvider, $serverContainer, $class); + $context->registerService($class, function () use ($provider) { + return $provider; + }); + $context->registerTranslationProvider($class); + } + } + + /** + * @psalm-suppress UndefinedClass, MissingDependency, InvalidReturnStatement, InvalidReturnType + */ + private function getAnonymousExAppProvider(TranslationProvider $provider, IServerContainer $serverContainer, string $class): ?ITranslationProviderWithId { + return new class($provider, $serverContainer, $class) implements ITranslationProviderWithId, ITranslationProviderWithUserId { + private ?string $userId; + + public function __construct( + private TranslationProvider $provider, + private IServerContainer $serverContainer, + private readonly string $class, + ) { + } + + public function getId(): string { + return $this->class; + } + + public function getName(): string { + return $this->provider->getDisplayName(); + } + + public function getAvailableLanguages(): array { + // $fromLanguages and $toLanguages are JSON objects with lang_code => lang_label paris { "language_code": "language_label" } + $fromLanguages = $this->provider->getFromLanguages(); + $toLanguages = $this->provider->getToLanguages(); + // Convert JSON objects to array of all possible LanguageTuple pairs + $availableLanguages = []; + foreach ($fromLanguages as $fromLanguageCode => $fromLanguageLabel) { + foreach ($toLanguages as $toLanguageCode => $toLanguageLabel) { + if ($fromLanguageCode === $toLanguageCode) { + continue; + } + $availableLanguages[] = LanguageTuple::fromArray([ + 'from' => $fromLanguageCode, + 'fromLabel' => $fromLanguageLabel, + 'to' => $toLanguageCode, + 'toLabel' => $toLanguageLabel, + ]); + } + } + return $availableLanguages; + } + + public function translate(?string $fromLanguage, string $toLanguage, string $text): string { + /** @var AppAPIService $service */ + $service = $this->serverContainer->get(AppAPIService::class); + /** @var TranslationQueueMapper $mapper */ + $mapper = $this->serverContainer->get(TranslationQueueMapper::class); + $route = $this->provider->getActionHandler(); + $queueRecord = $mapper->insert(new TranslationQueue(['created_time' => time()])); + $taskId = $queueRecord->getId(); + + $response = $service->requestToExAppById($this->provider->getAppid(), + $route, + $this->userId, + params: [ + 'from_language' => $fromLanguage, + 'to_language' => $toLanguage, + 'text' => $text, + ], + ); + + if (is_array($response)) { + $mapper->delete($mapper->getById($taskId)); + throw new \Exception(sprintf('Failed to process translation task: %s:%s:%s-%s. Error: %s', + $this->provider->getAppid(), + $this->provider->getName(), + $fromLanguage, + $toLanguage, + $response['error'] + )); + } + + do { + $taskResults = $mapper->getById($taskId); + usleep(300000); // 0.3s + } while ($taskResults->getFinished() === 0); + + $mapper->delete($taskResults); + if (!empty($taskResults->getError())) { + throw new \Exception(sprintf('Translation task returned error: %s:%s:%s-%s. Error: %s', + $this->provider->getAppid(), + $this->provider->getName(), + $fromLanguage, + $toLanguage, + $taskResults->getError(), + )); + } + return $taskResults->getResult(); + } + + public function setUserId(?string $userId): void { + $this->userId = $userId; + } + }; + } +}