diff --git a/docs/tech_details/api/machinetranslation.rst b/docs/tech_details/api/machinetranslation.rst index 150f88e3..47db9b6a 100644 --- a/docs/tech_details/api/machinetranslation.rst +++ b/docs/tech_details/api/machinetranslation.rst @@ -21,7 +21,6 @@ Request data { "name": "unique_provider_name", "display_name": "Provider Display Name", - "action_handler": "/handler_route_on_ex_app", "from_languages": { "en": "English", "fr": "French", @@ -30,11 +29,15 @@ Request data "en": "English", "fr": "French", }, + "action_handler": "/handler_route_on_ex_app", + "action_detect_lang": "/detect_lang_fromt_text_handler", } .. note:: ``from_languages`` and ``to_languages`` are JSON object with language code as key and language name as value. + ``action_detect_lang`` is optional. If provided, server's translation manager will call this handler to detect language from text if no source lang provided, + for reference see `Providing Language detection `_. Response diff --git a/lib/Controller/TranslationController.php b/lib/Controller/TranslationController.php index c80ee1f1..269e186a 100644 --- a/lib/Controller/TranslationController.php +++ b/lib/Controller/TranslationController.php @@ -43,6 +43,7 @@ public function registerProvider( array $fromLanguages, array $toLanguages, string $actionHandler, + string $actionDetectLang = '', ): DataResponse { $ncVersion = $this->config->getSystemValueString('version', '0.0.0'); if (version_compare($ncVersion, '29.0', '<')) { @@ -51,7 +52,7 @@ public function registerProvider( $provider = $this->translationService->registerTranslationProvider( $this->request->getHeader('EX-APP-ID'), $name, $displayName, - $fromLanguages, $toLanguages, $actionHandler + $fromLanguages, $toLanguages, $actionHandler, $actionDetectLang, ); if ($provider === null) { diff --git a/lib/Db/Translation/TranslationProvider.php b/lib/Db/Translation/TranslationProvider.php index d7ecc2a2..cf55f5ce 100644 --- a/lib/Db/Translation/TranslationProvider.php +++ b/lib/Db/Translation/TranslationProvider.php @@ -17,12 +17,14 @@ * @method array getFromLanguages() * @method array getToLanguages() * @method string getActionHandler() + * @method string getActionDetectLang() * @method void setAppid(string $appid) * @method void setName(string $name) * @method void setDisplayName(string $displayName) * @method void setFromLanguages(array $fromLanguages) * @method void setToLanguages(array $toLanguages) * @method void setActionHandler(string $actionHandler) + * @method void setActionDetectLang(string $actionDetectLang) */ class TranslationProvider extends Entity implements \JsonSerializable { protected $appid; @@ -31,6 +33,7 @@ class TranslationProvider extends Entity implements \JsonSerializable { protected $fromLanguages; protected $toLanguages; protected $actionHandler; + protected $actionDetectLang; public function __construct(array $params = []) { $this->addType('appid', 'string'); @@ -39,6 +42,7 @@ public function __construct(array $params = []) { $this->addType('fromLanguages', 'json'); $this->addType('toLanguages', 'json'); $this->addType('actionHandler', 'string'); + $this->addType('actionDetectLang', 'string'); if (isset($params['id'])) { $this->setId($params['id']); @@ -61,6 +65,9 @@ public function __construct(array $params = []) { if (isset($params['action_handler'])) { $this->setActionHandler($params['action_handler']); } + if (isset($params['action_detect_lang'])) { + $this->setActionDetectLang($params['action_detect_lang']); + } } public function jsonSerialize(): array { @@ -72,6 +79,7 @@ public function jsonSerialize(): array { 'from_languages' => $this->getFromLanguages(), 'to_languages' => $this->getToLanguages(), 'action_handler' => $this->getActionHandler(), + 'action_detect_lang' => $this->getActionDetectLang(), ]; } } diff --git a/lib/Migration/Version2200Date20240216164351.php b/lib/Migration/Version2200Date20240216164351.php new file mode 100644 index 00000000..e7fe61e0 --- /dev/null +++ b/lib/Migration/Version2200Date20240216164351.php @@ -0,0 +1,37 @@ +hasTable('ex_translation')) { + $table = $schema->getTable('ex_translation'); + + $table->addColumn('action_detect_lang', Types::STRING, [ + 'notnull' => false, + 'length' => 410, + 'default' => '', + ]); + } + + return $schema; + } +} diff --git a/lib/Service/ProvidersAI/TranslationService.php b/lib/Service/ProvidersAI/TranslationService.php index 89b498b7..6a43fecb 100644 --- a/lib/Service/ProvidersAI/TranslationService.php +++ b/lib/Service/ProvidersAI/TranslationService.php @@ -17,6 +17,7 @@ use OCP\ICache; use OCP\ICacheFactory; use OCP\IServerContainer; +use OCP\Translation\IDetectLanguageProvider; use OCP\Translation\ITranslationProviderWithId; use OCP\Translation\ITranslationProviderWithUserId; use OCP\Translation\LanguageTuple; @@ -39,7 +40,8 @@ public function registerTranslationProvider( string $displayName, array $fromLanguages, array $toLanguages, - string $actionHandler + string $actionHandler, + string $actionDetectLang, ): ?TranslationProvider { try { $translationProvider = $this->mapper->findByAppidName($appId, $name); @@ -54,6 +56,7 @@ public function registerTranslationProvider( 'from_languages' => $fromLanguages, 'to_languages' => $toLanguages, 'action_handler' => ltrim($actionHandler, '/'), + 'action_detect_lang' => ltrim($actionDetectLang, '/'), ]); if ($translationProvider !== null) { $newTranslationProvider->setId($translationProvider->getId()); @@ -139,7 +142,12 @@ public function registerExAppTranslationProviders(IRegistrationContext &$context $exAppsProviders = $this->getRegisteredTranslationProviders(); foreach ($exAppsProviders as $exAppProvider) { $class = '\\OCA\\AppAPI\\' . $exAppProvider->getAppid() . '\\' . $exAppProvider->getName(); - $provider = $this->getAnonymousExAppProvider($exAppProvider, $serverContainer, $class); + // IDetectLanguageProvider implementation is optional if ExApp has action_detect_lang + if ($exAppProvider->getActionDetectLang() !== '') { + $provider = $this->getAnonymousExAppIDetectLanguageProvider($exAppProvider, $serverContainer, $class); + } else { + $provider = $this->getAnonymousExAppProvider($exAppProvider, $serverContainer, $class); + } $context->registerService($class, function () use ($provider) { return $provider; }); @@ -152,101 +160,159 @@ public function registerExAppTranslationProviders(IRegistrationContext &$context */ 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, + TranslationProvider $provider, + IServerContainer $serverContainer, + string $class, ) { + $this->provider = $provider; + $this->serverContainer = $serverContainer; + $this->class = $class; } - public function getId(): string { - return $this->class; - } + use TranslationProviderWithIdAndUserId; + }; + } - public function getName(): string { - return $this->provider->getDisplayName(); + /** + * @psalm-suppress UndefinedClass, MissingDependency, InvalidReturnStatement, InvalidReturnType + */ + private function getAnonymousExAppIDetectLanguageProvider(TranslationProvider $provider, IServerContainer $serverContainer, string $class): ?IDetectLanguageProvider { + return new class($provider, $serverContainer, $class) implements ITranslationProviderWithId, ITranslationProviderWithUserId, IDetectLanguageProvider { + public function __construct( + TranslationProvider $provider, + IServerContainer $serverContainer, + string $class, + ) { + $this->provider = $provider; + $this->serverContainer = $serverContainer; + $this->class = $class; } - 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; - } + use TranslationProviderWithIdAndUserId; - public function translate(?string $fromLanguage, string $toLanguage, string $text, float $maxExecutionTime = 0): string { + public function detectLanguage(string $text): ?string { /** @var PublicFunctions $service */ $service = $this->serverContainer->get(PublicFunctions::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(); + $logger = $this->serverContainer->get(LoggerInterface::class); + $route = $this->provider->getActionDetectLang(); + + if ($route === '') { + return null; // ExApp does not support language detection + } $response = $service->exAppRequestWithUserInit($this->provider->getAppid(), $route, $this->userId, params: [ - 'from_language' => $fromLanguage, - 'to_language' => $toLanguage, 'text' => $text, - 'task_id' => $taskId, - 'max_execution_time' => $maxExecutionTime, - ], - options: [ - 'timeout' => $maxExecutionTime, ], ); + $response = json_decode($response->getBody(), true); - 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'] - )); - } + $logger->debug('Detect language response ' . json_encode($response)); - 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(), - )); + if (isset($response['error'])) { + throw new \Exception(sprintf('Failed to detect language for text: %s. Error: %s', $text, $response['error'])); } - return $taskResults->getResult(); - } - public function setUserId(?string $userId): void { - $this->userId = $userId; + return $response['detected_lang'] ?? null; } }; } } + + +trait TranslationProviderWithIdAndUserId { + private ?string $userId; + private IServerContainer $serverContainer; + private TranslationProvider $provider; + private 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, float $maxExecutionTime = 0): string { + /** @var PublicFunctions $service */ + $service = $this->serverContainer->get(PublicFunctions::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->exAppRequestWithUserInit($this->provider->getAppid(), + $route, + $this->userId, + params: [ + 'from_language' => $fromLanguage, + 'to_language' => $toLanguage, + 'text' => $text, + 'task_id' => $taskId, + 'max_execution_time' => $maxExecutionTime, + ], + options: [ + 'timeout' => $maxExecutionTime, + ], + ); + + 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; + } +}