src/WebService/TranslationWebService.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\WebService;
use Exception;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;
/**
* Multipurpose class:
* - 1) Interface for web services.
* - 2) Source text picking logic.
* - 3) Factory class.
* - 4) Service failure tracking and suspending.
* @author Niklas Laxström
* @license GPL-2.0-or-later
* @since 2013-01-01
* @defgroup TranslationWebService Translation Web Services
*/
abstract class TranslationWebService implements LoggerAwareInterface {
private ?BagOStuff $cache;
/* Public api */
/**
* Get a webservice handler.
* @see $wgTranslateTranslationServices
*/
public static function factory( string $serviceName, array $config ): ?TranslationWebService {
$handlers = [
'microsoft' => [
'class' => MicrosoftWebService::class,
'deps' => [ 'HttpRequestFactory' ]
],
'apertium' => [
'class' => ApertiumWebService::class,
'deps' => [ 'HttpRequestFactory' ]
],
'yandex' => [
'class' => YandexWebService::class,
'deps' => [ 'HttpRequestFactory' ]
],
'google' => [
'class' => GoogleTranslateWebService::class,
'deps' => [ 'HttpRequestFactory' ]
],
'remote-ttmserver' => [
'class' => RemoteTTMServerWebService::class
],
'cxserver' => [
'class' => ApertiumCxserverWebService::class,
'deps' => [ 'HttpRequestFactory' ]
],
'caighdean' => [
'class' => CaighdeanWebService::class
],
'mint' => [
'class' => MintCxserverWebService::class,
'deps' => [ 'HttpRequestFactory' ]
]
];
if ( !isset( $config['timeout'] ) ) {
$config['timeout'] = 3;
}
$serviceDetails = $handlers[$config['type']] ?? null;
if ( $serviceDetails ) {
$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
$spec = [
'class' => $serviceDetails['class'],
'args' => [ $serviceName, $config ],
'services' => $serviceDetails['deps'] ?? [],
];
// @phan-suppress-next-line PhanTypeInvalidCallableArraySize due to annotations on createObject?
$serviceObject = $objectFactory->createObject( $spec );
if ( $serviceObject instanceof LoggerAwareInterface ) {
$serviceObject->setLogger( LoggerFactory::getInstance( 'translationservices' ) );
}
return $serviceObject;
}
return null;
}
/**
* Gets the name of this service, for example to display it for the user.
* @since 2014.02
*/
public function getName(): string {
return $this->service;
}
/**
* Get queries for this service. Queries from multiple services can be
* collected and run asynchronously with QueryAggregator.
* @return TranslationQuery[]
* @since 2015.12
* @throws TranslationWebServiceConfigurationException
*/
public function getQueries( string $text, string $sourceLanguage, string $targetLanguage ): array {
$from = $this->mapCode( $sourceLanguage );
$to = $this->mapCode( $targetLanguage );
try {
return [ $this->getQuery( $text, $from, $to ) ];
} catch ( TranslationWebServiceException $e ) {
$this->reportTranslationServiceFailure( $e->getMessage() );
return [];
} catch ( TranslationWebServiceInvalidInputException $e ) {
// Not much we can do about this, just ignore.
return [];
}
}
/**
* Get the web service specific response returned by QueryAggregator.
* @return mixed|null Returns null on error.
* @since 2015.12
*/
public function getResultData( TranslationQueryResponse $response ) {
if ( $response->getStatusCode() !== 200 ) {
$this->reportTranslationServiceFailure(
'STATUS: ' . $response->getStatusMessage() . "\n" .
'BODY: ' . $response->getBody()
);
return null;
}
try {
return $this->parseResponse( $response );
} catch ( TranslationWebServiceException $e ) {
$this->reportTranslationServiceFailure( $e->getMessage() );
return null;
} catch ( TranslationWebServiceInvalidInputException $e ) {
// Not much we can do about this, just ignore.
return null;
}
}
/**
* Returns the type of this web service.
* @see \MediaWiki\Extension\Translate\TranslatorInterface\Aid\TranslationAid::getTypes
*/
abstract public function getType(): string;
/* Service api */
/**
* Map a MediaWiki (almost standard) language code to the code used by the
* translation service.
*/
abstract protected function mapCode( string $code ): string;
/**
* Get the list of supported language pairs for the web service. The codes
* should be the ones used by the service. Caching is handled by the public
* getSupportedLanguagePairs.
* @return array $list[source language][target language] = true
* @throws TranslationWebServiceException
* @throws TranslationWebServiceConfigurationException
*/
abstract protected function doPairs(): array;
/**
* Get the query. See getQueries for the public method.
* @param string $text Text to translate.
* @param string $sourceLanguage Language code of the text, as used by the service.
* @param string $targetLanguage Language code of the translation, as used by the service.
* @since 2015.02
* @throws TranslationWebServiceException
* @throws TranslationWebServiceConfigurationException
* @throws TranslationWebServiceInvalidInputException
*/
abstract protected function getQuery(
string $text, string $sourceLanguage, string $targetLanguage
): TranslationQuery;
/**
* Get the response. See getResultData for the public method.
* @since 2015.02
* @throws TranslationWebServiceException
*/
abstract protected function parseResponse( TranslationQueryResponse $response );
/* Default implementation */
/** @var string Name of this webservice. */
protected $service;
/** @var array */
protected $config;
/** @var LoggerInterface */
protected $logger;
public function __construct( string $service, array $config ) {
$this->service = $service;
$this->config = $config;
}
/**
* Test whether given language pair is supported by the service.
* @since 2015.12
* @throws TranslationWebServiceConfigurationException
*/
public function isSupportedLanguagePair( string $sourceLanguage, string $targetLanguage ): bool {
$pairs = $this->getSupportedLanguagePairs();
$from = $this->mapCode( $sourceLanguage );
$to = $this->mapCode( $targetLanguage );
return isset( $pairs[$from][$to] );
}
/**
* @see self::doPairs
* @throws TranslationWebServiceConfigurationException
*/
protected function getSupportedLanguagePairs(): array {
$cache = $this->getObjectCache();
return $cache->getWithSetCallback(
$cache->makeKey( 'translate-tmsug-pairs-' . $this->service ),
$cache::TTL_DAY,
function ( &$ttl ) use ( $cache ) {
try {
$pairs = $this->doPairs();
} catch ( Exception $e ) {
$pairs = [];
$this->reportTranslationServiceFailure( $e->getMessage() );
$ttl = $cache::TTL_UNCACHEABLE;
}
return $pairs;
}
);
}
/**
* Some mangling that tries to keep some parts of the message unmangled
* by the translation service. Most of them support either class=notranslate
* or translate=no.
*/
protected function wrapUntranslatable( string $text ): string {
$text = str_replace( "\n", '!N!', $text );
$pattern = '~%[^% ]+%|\$\d|{VAR:[^}]+}|{?{(PLURAL|GRAMMAR|GENDER):[^|]+\||%(\d\$)?[sd]~';
$wrap = '<span class="notranslate" translate="no">\0</span>';
return preg_replace( $pattern, $wrap, $text );
}
/** Undo the hopyfully untouched mangling done by wrapUntranslatable. */
protected function unwrapUntranslatable( string $text ): string {
$text = str_replace( '!N!', "\n", $text );
$pattern = '~<span class="notranslate" translate="no">(.*?)</span>~';
return preg_replace( $pattern, '\1', $text );
}
/* Failure handling and suspending */
public function setLogger( LoggerInterface $logger ): void {
$this->logger = $logger;
}
/**
* @var int How many failures during failure period need to happen to
* consider the service being temporarily off-line.
*/
protected $serviceFailureCount = 5;
/**
* @var int How long after the last detected failure we clear the status and
* try again.
*/
protected $serviceFailurePeriod = 900;
/** Checks whether the service has exceeded failure count */
public function checkTranslationServiceFailure(): bool {
$service = $this->service;
$cache = $this->getObjectCache();
$key = $cache->makeKey( "translate-service-$service" );
$value = $cache->get( $key );
if ( !is_string( $value ) ) {
return false;
}
[ $count, $failed ] = explode( '|', $value, 2 );
$count = (int)$count;
$failed = (int)$failed;
$now = (int)wfTimestamp();
if ( $failed + ( 2 * $this->serviceFailurePeriod ) < $now ) {
if ( $count >= $this->serviceFailureCount ) {
$this->logger->warning( "Translation service $service (was) restored" );
}
$cache->delete( $key );
return false;
} elseif ( $failed + $this->serviceFailurePeriod < $now ) {
/* We are in suspicious mode and one failure is enough to update
* failed timestamp. If the service works however, let's use it.
* Previous failures are forgotten after another failure period
* has passed */
return false;
}
// Check the failure count against the limit
return $count >= $this->serviceFailureCount;
}
/** Increases the failure count for this service */
protected function reportTranslationServiceFailure( string $msg ): void {
$service = $this->service;
$this->logger->warning( "Translation service $service problem: $msg" );
$cache = $this->getObjectCache();
$key = $cache->makeKey( "translate-service-$service" );
$value = $cache->get( $key );
if ( !is_string( $value ) ) {
$count = 0;
} else {
[ $count, ] = explode( '|', $value, 2 );
}
$count++;
$failed = wfTimestamp();
$cache->set(
$key,
"$count|$failed",
$this->serviceFailurePeriod * 5
);
if ( $count === $this->serviceFailureCount ) {
$this->logger->error( "Translation service $service suspended" );
} elseif ( $count > $this->serviceFailureCount ) {
$this->logger->warning( "Translation service $service still suspended" );
}
}
private function getObjectCache(): BagOStuff {
$this->cache ??= MediaWikiServices::getInstance()
->getObjectCacheFactory()
->getInstance( CACHE_ANYTHING );
return $this->cache;
}
}