Explorar el Código

feat(translations): Add translation provider API

Signed-off-by: Julius Härtl <jus@bitgrid.net>
Julius Härtl hace 1 año
padre
commit
3e63298381

+ 66 - 0
core/Controller/TranslationApiController.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OC\Core\Controller;
+
+use InvalidArgumentException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\IRequest;
+use OCP\PreConditionNotMetException;
+use OCP\Translation\ITranslationManager;
+use RuntimeException;
+
+class TranslationApiController extends \OCP\AppFramework\OCSController {
+	private ITranslationManager $translationManager;
+
+	public function __construct($appName, IRequest $request, ITranslationManager $translationManager) {
+		parent::__construct($appName, $request);
+
+		$this->translationManager = $translationManager;
+	}
+
+	public function languages(): DataResponse {
+		return new DataResponse([
+			'languages' => $this->translationManager->getLanguages(),
+			'languageDetection' => $this->translationManager->canDetectLanguage(),
+		]);
+	}
+
+	public function translate(string $text, ?string $fromLanguage, string $toLanguage): DataResponse {
+		try {
+			return new DataResponse([
+				'text' => $this->translationManager->translate($text, $fromLanguage, $toLanguage)
+			]);
+		} catch (PreConditionNotMetException) {
+			return new DataResponse(['message' => 'No translation provider available'], Http::STATUS_PRECONDITION_FAILED);
+		} catch (InvalidArgumentException) {
+			return new DataResponse(['message' => 'Could not detect language', Http::STATUS_NOT_FOUND]);
+		} catch (RuntimeException) {
+			return new DataResponse(['message' => 'Unable to translate', Http::STATUS_INTERNAL_SERVER_ERROR]);
+		}
+	}
+}

+ 2 - 0
core/routes.php

@@ -143,6 +143,8 @@ $application->registerRoutes($this, [
 		['root' => '/search', 'name' => 'UnifiedSearch#getProviders', 'url' => '/providers', 'verb' => 'GET'],
 		['root' => '/search', 'name' => 'UnifiedSearch#search', 'url' => '/providers/{providerId}/search', 'verb' => 'GET'],
 
+		['root' => '/translation', 'name' => 'TranslationApi#languages', 'url' => '/languages', 'verb' => 'GET'],
+		['root' => '/translation', 'name' => 'TranslationApi#translate', 'url' => '/translate', 'verb' => 'POST'],
 	],
 ]);
 

+ 6 - 0
lib/composer/composer/autoload_classmap.php

@@ -590,6 +590,10 @@ return array(
     'OCP\\Talk\\IConversationOptions' => $baseDir . '/lib/public/Talk/IConversationOptions.php',
     'OCP\\Talk\\ITalkBackend' => $baseDir . '/lib/public/Talk/ITalkBackend.php',
     'OCP\\Template' => $baseDir . '/lib/public/Template.php',
+    'OCP\\Translation\\IDetectLanguageProvider' => $baseDir . '/lib/public/Translation/IDetectLanguageProvider.php',
+    'OCP\\Translation\\ITranslationManager' => $baseDir . '/lib/public/Translation/ITranslationManager.php',
+    'OCP\\Translation\\ITranslationProvider' => $baseDir . '/lib/public/Translation/ITranslationProvider.php',
+    'OCP\\Translation\\LanguageTuple' => $baseDir . '/lib/public/Translation/LanguageTuple.php',
     'OCP\\UserInterface' => $baseDir . '/lib/public/UserInterface.php',
     'OCP\\UserMigration\\IExportDestination' => $baseDir . '/lib/public/UserMigration/IExportDestination.php',
     'OCP\\UserMigration\\IImportSource' => $baseDir . '/lib/public/UserMigration/IImportSource.php',
@@ -1005,6 +1009,7 @@ return array(
     'OC\\Core\\Controller\\ReferenceController' => $baseDir . '/core/Controller/ReferenceController.php',
     'OC\\Core\\Controller\\SearchController' => $baseDir . '/core/Controller/SearchController.php',
     'OC\\Core\\Controller\\SetupController' => $baseDir . '/core/Controller/SetupController.php',
+    'OC\\Core\\Controller\\TranslationApiController' => $baseDir . '/core/Controller/TranslationApiController.php',
     'OC\\Core\\Controller\\TwoFactorChallengeController' => $baseDir . '/core/Controller/TwoFactorChallengeController.php',
     'OC\\Core\\Controller\\UnifiedSearchController' => $baseDir . '/core/Controller/UnifiedSearchController.php',
     'OC\\Core\\Controller\\UnsupportedBrowserController' => $baseDir . '/core/Controller/UnsupportedBrowserController.php',
@@ -1598,6 +1603,7 @@ return array(
     'OC\\Template\\ResourceLocator' => $baseDir . '/lib/private/Template/ResourceLocator.php',
     'OC\\Template\\ResourceNotFoundException' => $baseDir . '/lib/private/Template/ResourceNotFoundException.php',
     'OC\\Template\\TemplateFileLocator' => $baseDir . '/lib/private/Template/TemplateFileLocator.php',
+    'OC\\Translation\\TranslationManager' => $baseDir . '/lib/private/Translation/TranslationManager.php',
     'OC\\URLGenerator' => $baseDir . '/lib/private/URLGenerator.php',
     'OC\\Updater' => $baseDir . '/lib/private/Updater.php',
     'OC\\Updater\\ChangesCheck' => $baseDir . '/lib/private/Updater/ChangesCheck.php',

+ 6 - 0
lib/composer/composer/autoload_static.php

@@ -623,6 +623,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OCP\\Talk\\IConversationOptions' => __DIR__ . '/../../..' . '/lib/public/Talk/IConversationOptions.php',
         'OCP\\Talk\\ITalkBackend' => __DIR__ . '/../../..' . '/lib/public/Talk/ITalkBackend.php',
         'OCP\\Template' => __DIR__ . '/../../..' . '/lib/public/Template.php',
+        'OCP\\Translation\\IDetectLanguageProvider' => __DIR__ . '/../../..' . '/lib/public/Translation/IDetectLanguageProvider.php',
+        'OCP\\Translation\\ITranslationManager' => __DIR__ . '/../../..' . '/lib/public/Translation/ITranslationManager.php',
+        'OCP\\Translation\\ITranslationProvider' => __DIR__ . '/../../..' . '/lib/public/Translation/ITranslationProvider.php',
+        'OCP\\Translation\\LanguageTuple' => __DIR__ . '/../../..' . '/lib/public/Translation/LanguageTuple.php',
         'OCP\\UserInterface' => __DIR__ . '/../../..' . '/lib/public/UserInterface.php',
         'OCP\\UserMigration\\IExportDestination' => __DIR__ . '/../../..' . '/lib/public/UserMigration/IExportDestination.php',
         'OCP\\UserMigration\\IImportSource' => __DIR__ . '/../../..' . '/lib/public/UserMigration/IImportSource.php',
@@ -1038,6 +1042,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OC\\Core\\Controller\\ReferenceController' => __DIR__ . '/../../..' . '/core/Controller/ReferenceController.php',
         'OC\\Core\\Controller\\SearchController' => __DIR__ . '/../../..' . '/core/Controller/SearchController.php',
         'OC\\Core\\Controller\\SetupController' => __DIR__ . '/../../..' . '/core/Controller/SetupController.php',
+        'OC\\Core\\Controller\\TranslationApiController' => __DIR__ . '/../../..' . '/core/Controller/TranslationApiController.php',
         'OC\\Core\\Controller\\TwoFactorChallengeController' => __DIR__ . '/../../..' . '/core/Controller/TwoFactorChallengeController.php',
         'OC\\Core\\Controller\\UnifiedSearchController' => __DIR__ . '/../../..' . '/core/Controller/UnifiedSearchController.php',
         'OC\\Core\\Controller\\UnsupportedBrowserController' => __DIR__ . '/../../..' . '/core/Controller/UnsupportedBrowserController.php',
@@ -1631,6 +1636,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OC\\Template\\ResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceLocator.php',
         'OC\\Template\\ResourceNotFoundException' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceNotFoundException.php',
         'OC\\Template\\TemplateFileLocator' => __DIR__ . '/../../..' . '/lib/private/Template/TemplateFileLocator.php',
+        'OC\\Translation\\TranslationManager' => __DIR__ . '/../../..' . '/lib/private/Translation/TranslationManager.php',
         'OC\\URLGenerator' => __DIR__ . '/../../..' . '/lib/private/URLGenerator.php',
         'OC\\Updater' => __DIR__ . '/../../..' . '/lib/private/Updater.php',
         'OC\\Updater\\ChangesCheck' => __DIR__ . '/../../..' . '/lib/private/Updater/ChangesCheck.php',

+ 25 - 0
lib/private/AppFramework/Bootstrap/RegistrationContext.php

@@ -34,6 +34,7 @@ use OCP\Calendar\Resource\IBackend as IResourceBackend;
 use OCP\Calendar\Room\IBackend as IRoomBackend;
 use OCP\Collaboration\Reference\IReferenceProvider;
 use OCP\Talk\ITalkBackend;
+use OCP\Translation\ITranslationProvider;
 use RuntimeException;
 use function array_shift;
 use OC\Support\CrashReport\Registry;
@@ -113,6 +114,9 @@ class RegistrationContext {
 	/** @var ServiceRegistration<ICustomTemplateProvider>[] */
 	private $templateProviders = [];
 
+	/** @var ServiceRegistration<ITranslationProvider>[] */
+	private $translationProviders = [];
+
 	/** @var ServiceRegistration<INotifier>[] */
 	private $notifierServices = [];
 
@@ -125,6 +129,9 @@ class RegistrationContext {
 	/** @var ServiceRegistration<IReferenceProvider>[] */
 	private array $referenceProviders = [];
 
+
+
+
 	/** @var ParameterRegistration[] */
 	private $sensitiveMethods = [];
 
@@ -252,6 +259,13 @@ class RegistrationContext {
 				);
 			}
 
+			public function registerTranslationProvider(string $providerClass): void {
+				$this->context->registerTranslationProvider(
+					$this->appId,
+					$providerClass
+				);
+			}
+
 			public function registerNotifierService(string $notifierClass): void {
 				$this->context->registerNotifierService(
 					$this->appId,
@@ -404,6 +418,10 @@ class RegistrationContext {
 		$this->templateProviders[] = new ServiceRegistration($appId, $class);
 	}
 
+	public function registerTranslationProvider(string $appId, string $class): void {
+		$this->translationProviders[] = new ServiceRegistration($appId, $class);
+	}
+
 	public function registerNotifierService(string $appId, string $class): void {
 		$this->notifierServices[] = new ServiceRegistration($appId, $class);
 	}
@@ -674,6 +692,13 @@ class RegistrationContext {
 		return $this->templateProviders;
 	}
 
+	/**
+	 * @return ServiceRegistration<ITranslationProvider>[]
+	 */
+	public function getTranslationProviders(): array {
+		return $this->translationProviders;
+	}
+
 	/**
 	 * @return ServiceRegistration<INotifier>[]
 	 */

+ 4 - 0
lib/private/Server.php

@@ -152,6 +152,7 @@ use OC\SystemTag\ManagerFactory as SystemTagManagerFactory;
 use OC\Tagging\TagMapper;
 use OC\Talk\Broker;
 use OC\Template\JSCombiner;
+use OC\Translation\TranslationManager;
 use OC\User\DisplayNameCache;
 use OC\User\Listeners\BeforeUserDeletedListener;
 use OC\User\Listeners\UserChangedListener;
@@ -247,6 +248,7 @@ use OCP\Share\IShareHelper;
 use OCP\SystemTag\ISystemTagManager;
 use OCP\SystemTag\ISystemTagObjectMapper;
 use OCP\Talk\IBroker;
+use OCP\Translation\ITranslationManager;
 use OCP\User\Events\BeforePasswordUpdatedEvent;
 use OCP\User\Events\BeforeUserDeletedEvent;
 use OCP\User\Events\BeforeUserLoggedInEvent;
@@ -1453,6 +1455,8 @@ class Server extends ServerContainer implements IServerContainer {
 
 		$this->registerAlias(\OCP\Share\IPublicShareTemplateFactory::class, \OC\Share20\PublicShareTemplateFactory::class);
 
+		$this->registerAlias(ITranslationManager::class, TranslationManager::class);
+
 		$this->connectDispatcher();
 	}
 

+ 120 - 0
lib/private/Translation/TranslationManager.php

@@ -0,0 +1,120 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OC\Translation;
+
+use InvalidArgumentException;
+use OC\AppFramework\Bootstrap\Coordinator;
+use OCP\IServerContainer;
+use OCP\PreConditionNotMetException;
+use OCP\Translation\IDetectLanguageProvider;
+use OCP\Translation\ITranslationManager;
+use OCP\Translation\ITranslationProvider;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\NotFoundExceptionInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Throwable;
+
+class TranslationManager implements ITranslationManager {
+	/** @var ?ITranslationProvider[] */
+	private ?array $providers = null;
+
+	public function __construct(
+		private IServerContainer $serverContainer,
+		private Coordinator $coordinator,
+		private LoggerInterface $logger,
+	) {
+	}
+
+	public function getLanguages(): array {
+		$languages = [];
+		foreach ($this->getProviders() as $provider) {
+			$languages = array_merge($languages, $provider->getAvailableLanguages());
+		}
+		return $languages;
+	}
+
+	public function translate(string $text, ?string $fromLanguage, string $toLanguage): string {
+		if (!$this->hasProviders()) {
+			throw new PreConditionNotMetException('No translation providers available');
+		}
+
+		foreach ($this->getProviders() as $provider) {
+			if ($fromLanguage === null && $provider instanceof IDetectLanguageProvider) {
+				$fromLanguage = $provider->detectLanguage($text);
+			}
+
+			if ($fromLanguage === null) {
+				throw new InvalidArgumentException('Could not detect language');
+			}
+
+			try {
+				return $provider->translate($fromLanguage, $toLanguage, $text);
+			} catch (RuntimeException $e) {
+				$this->logger->warning("Failed to translate from {$fromLanguage} to {$toLanguage}", ['exception' => $e]);
+			}
+		}
+
+		throw new RuntimeException('Could not translate text');
+	}
+
+	public function getProviders(): array {
+		$context = $this->coordinator->getRegistrationContext();
+
+		if ($this->providers !== null) {
+			return $this->providers;
+		}
+
+		$this->providers = [];
+		foreach ($context->getTranslationProviders() as $providerRegistration) {
+			$class = $providerRegistration->getService();
+			try {
+				$this->providers[$class] = $this->serverContainer->get($class);
+			} catch (NotFoundExceptionInterface|ContainerExceptionInterface|Throwable $e) {
+				$this->logger->error('Failed to load translation provider ' . $class, [
+					'exception' => $e
+				]);
+			}
+		}
+
+		return $this->providers;
+	}
+
+	public function hasProviders(): bool {
+		$context = $this->coordinator->getRegistrationContext();
+		return !empty($context->getTranslationProviders());
+	}
+
+	public function canDetectLanguage(): bool {
+		foreach ($this->getProviders() as $provider) {
+			if ($provider instanceof IDetectLanguageProvider) {
+				return true;
+			}
+		}
+		return false;
+	}
+}

+ 11 - 0
lib/public/AppFramework/Bootstrap/IRegistrationContext.php

@@ -39,6 +39,7 @@ use OCP\Files\Template\ICustomTemplateProvider;
 use OCP\IContainer;
 use OCP\Notification\INotifier;
 use OCP\Preview\IProviderV2;
+use OCP\Translation\ITranslationProvider;
 
 /**
  * The context object passed to IBootstrap::register
@@ -217,6 +218,16 @@ interface IRegistrationContext {
 	 */
 	public function registerTemplateProvider(string $providerClass): void;
 
+	/**
+	 * Register a custom translation provider class that can provide translation
+	 * between languages through the OCP\Translation APIs
+	 *
+	 * @param string $providerClass
+	 * @psalm-param class-string<ITranslationProvider> $providerClass
+	 * @since 21.0.0
+	 */
+	public function registerTranslationProvider(string $providerClass): void;
+
 	/**
 	 * Register an INotifier class
 	 *

+ 39 - 0
lib/public/Translation/IDetectLanguageProvider.php

@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OCP\Translation;
+
+/**
+ * @since 26.0.0
+ */
+interface IDetectLanguageProvider {
+	/**
+	 * Try to detect the language of a given string
+	 *
+	 * @since 26.0.0
+	 */
+	public function detectLanguage(string $text): ?string;
+}

+ 60 - 0
lib/public/Translation/ITranslationManager.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OCP\Translation;
+
+use InvalidArgumentException;
+use OCP\PreConditionNotMetException;
+use RuntimeException;
+
+/**
+ * @since 26.0.0
+ */
+interface ITranslationManager {
+	/**
+	 * @since 26.0.0
+	 */
+	public function hasProviders(): bool;
+
+	/**
+	 * @since 26.0.0
+	 */
+	public function canDetectLanguage(): bool;
+
+	/**
+	 * @since 26.0.0
+	 * @return LanguageTuple[]
+	 */
+	public function getLanguages(): array;
+
+	/**
+	 * @since 26.0.0
+	 * @throws PreConditionNotMetException If no provider was registered but this method was still called
+	 * @throws InvalidArgumentException If no matching provider was found that can detect a language
+	 * @throws RuntimeException If the translation failed for other reasons
+	 */
+	public function translate(string $text, ?string $fromLanguage, string $toLanguage): string;
+}

+ 50 - 0
lib/public/Translation/ITranslationProvider.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OCP\Translation;
+
+use RuntimeException;
+
+/**
+ * @since 26.0.0
+ */
+interface ITranslationProvider {
+	/**
+	 * @since 26.0.0
+	 */
+	public function getName(): string;
+
+	/**
+	 * @since 26.0.0
+	 */
+	public function getAvailableLanguages(): array;
+
+	/**
+	 * @since 26.0.0
+	 * @throws RuntimeException If the text could not be translated
+	 */
+	public function translate(?string $fromLanguage, string $toLanguage, string $text): string;
+}

+ 69 - 0
lib/public/Translation/LanguageTuple.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2023 Julius Härtl <jus@bitgrid.net>
+ *
+ * @author Julius Härtl <jus@bitgrid.net>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+
+namespace OCP\Translation;
+
+use JsonSerializable;
+
+/**
+ * @since 26.0.0
+ */
+class LanguageTuple implements JsonSerializable {
+	/**
+	 * @since 26.0.0
+	 */
+	public function __construct(
+		private string $from,
+		private string $fromLabel,
+		private string $to,
+		private string $toLabel
+	) {
+	}
+
+	/**
+	 * @since 26.0.0
+	 */
+	public function jsonSerialize(): array {
+		return [
+			'from' => $this->from,
+			'fromLabel' => $this->fromLabel,
+			'to' => $this->to,
+			'toLabel' => $this->toLabel,
+		];
+	}
+
+	/**
+	 * @since 26.0.0
+	 */
+	public static function fromArray(array $data): LanguageTuple {
+		return new self(
+			$data['from'],
+			$data['fromLabel'],
+			$data['to'],
+			$data['toLabel'],
+		);
+	}
+}