Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 137 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
TranslationWebService | |
0.00% |
0 / 137 |
|
0.00% |
0 / 13 |
812 | |
0.00% |
0 / 1 |
factory | |
0.00% |
0 / 51 |
|
0.00% |
0 / 1 |
20 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getQueries | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getResultData | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
getType | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
mapCode | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
doPairs | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getQuery | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
0 | |||
parseResponse | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
isSupportedLanguagePair | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getSupportedLanguagePairs | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
wrapUntranslatable | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
unwrapUntranslatable | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
checkTranslationServiceFailure | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
reportTranslationServiceFailure | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\WebService; |
5 | |
6 | use Exception; |
7 | use MediaWiki\Logger\LoggerFactory; |
8 | use MediaWiki\MediaWikiServices; |
9 | use ObjectCache; |
10 | use Psr\Log\LoggerAwareInterface; |
11 | use Psr\Log\LoggerInterface; |
12 | |
13 | /** |
14 | * Multipurpose class: |
15 | * - 1) Interface for web services. |
16 | * - 2) Source text picking logic. |
17 | * - 3) Factory class. |
18 | * - 4) Service failure tracking and suspending. |
19 | * @author Niklas Laxström |
20 | * @license GPL-2.0-or-later |
21 | * @since 2013-01-01 |
22 | * @defgroup TranslationWebService Translation Web Services |
23 | */ |
24 | abstract class TranslationWebService implements LoggerAwareInterface { |
25 | /* Public api */ |
26 | |
27 | /** |
28 | * Get a webservice handler. |
29 | * @see $wgTranslateTranslationServices |
30 | */ |
31 | public static function factory( string $serviceName, array $config ): ?TranslationWebService { |
32 | $handlers = [ |
33 | 'microsoft' => [ |
34 | 'class' => MicrosoftWebService::class, |
35 | 'deps' => [ 'HttpRequestFactory' ] |
36 | ], |
37 | 'apertium' => [ |
38 | 'class' => ApertiumWebService::class, |
39 | 'deps' => [ 'HttpRequestFactory' ] |
40 | ], |
41 | 'yandex' => [ |
42 | 'class' => YandexWebService::class, |
43 | 'deps' => [ 'HttpRequestFactory' ] |
44 | ], |
45 | 'google' => [ |
46 | 'class' => GoogleTranslateWebService::class, |
47 | 'deps' => [ 'HttpRequestFactory' ] |
48 | ], |
49 | 'remote-ttmserver' => [ |
50 | 'class' => RemoteTTMServerWebService::class |
51 | ], |
52 | 'cxserver' => [ |
53 | 'class' => ApertiumCxserverWebService::class, |
54 | 'deps' => [ 'HttpRequestFactory' ] |
55 | ], |
56 | 'restbase' => [ |
57 | 'class' => RESTBaseWebService::class, |
58 | 'deps' => [ 'HttpRequestFactory' ] |
59 | ], |
60 | 'caighdean' => [ |
61 | 'class' => CaighdeanWebService::class |
62 | ], |
63 | 'mint' => [ |
64 | 'class' => MintCxserverWebService::class, |
65 | 'deps' => [ 'HttpRequestFactory' ] |
66 | ] |
67 | ]; |
68 | |
69 | if ( !isset( $config['timeout'] ) ) { |
70 | $config['timeout'] = 3; |
71 | } |
72 | |
73 | $serviceDetails = $handlers[$config['type']] ?? null; |
74 | if ( $serviceDetails ) { |
75 | $objectFactory = MediaWikiServices::getInstance()->getObjectFactory(); |
76 | $spec = [ |
77 | 'class' => $serviceDetails['class'], |
78 | 'args' => [ $serviceName, $config ], |
79 | 'services' => $serviceDetails['deps'] ?? [], |
80 | ]; |
81 | |
82 | // @phan-suppress-next-line PhanTypeInvalidCallableArraySize due to annotations on createObject? |
83 | $serviceObject = $objectFactory->createObject( $spec ); |
84 | if ( $serviceObject instanceof LoggerAwareInterface ) { |
85 | $serviceObject->setLogger( LoggerFactory::getInstance( 'translationservices' ) ); |
86 | } |
87 | |
88 | return $serviceObject; |
89 | } |
90 | |
91 | return null; |
92 | } |
93 | |
94 | /** |
95 | * Gets the name of this service, for example to display it for the user. |
96 | * @since 2014.02 |
97 | */ |
98 | public function getName(): string { |
99 | return $this->service; |
100 | } |
101 | |
102 | /** |
103 | * Get queries for this service. Queries from multiple services can be |
104 | * collected and run asynchronously with QueryAggregator. |
105 | * @return TranslationQuery[] |
106 | * @since 2015.12 |
107 | * @throws TranslationWebServiceConfigurationException |
108 | */ |
109 | public function getQueries( string $text, string $sourceLanguage, string $targetLanguage ): array { |
110 | $from = $this->mapCode( $sourceLanguage ); |
111 | $to = $this->mapCode( $targetLanguage ); |
112 | |
113 | try { |
114 | return [ $this->getQuery( $text, $from, $to ) ]; |
115 | } catch ( TranslationWebServiceException $e ) { |
116 | $this->reportTranslationServiceFailure( $e->getMessage() ); |
117 | return []; |
118 | } catch ( TranslationWebServiceInvalidInputException $e ) { |
119 | // Not much we can do about this, just ignore. |
120 | return []; |
121 | } |
122 | } |
123 | |
124 | /** |
125 | * Get the web service specific response returned by QueryAggregator. |
126 | * @return mixed|null Returns null on error. |
127 | * @since 2015.12 |
128 | */ |
129 | public function getResultData( TranslationQueryResponse $response ) { |
130 | if ( $response->getStatusCode() !== 200 ) { |
131 | $this->reportTranslationServiceFailure( |
132 | 'STATUS: ' . $response->getStatusMessage() . "\n" . |
133 | 'BODY: ' . $response->getBody() |
134 | ); |
135 | return null; |
136 | } |
137 | |
138 | try { |
139 | return $this->parseResponse( $response ); |
140 | } catch ( TranslationWebServiceException $e ) { |
141 | $this->reportTranslationServiceFailure( $e->getMessage() ); |
142 | return null; |
143 | } catch ( TranslationWebServiceInvalidInputException $e ) { |
144 | // Not much we can do about this, just ignore. |
145 | return null; |
146 | } |
147 | } |
148 | |
149 | /** |
150 | * Returns the type of this web service. |
151 | * @see \MediaWiki\Extension\Translate\TranslatorInterface\Aid\TranslationAid::getTypes |
152 | */ |
153 | abstract public function getType(): string; |
154 | |
155 | /* Service api */ |
156 | |
157 | /** |
158 | * Map a MediaWiki (almost standard) language code to the code used by the |
159 | * translation service. |
160 | */ |
161 | abstract protected function mapCode( string $code ): string; |
162 | |
163 | /** |
164 | * Get the list of supported language pairs for the web service. The codes |
165 | * should be the ones used by the service. Caching is handled by the public |
166 | * getSupportedLanguagePairs. |
167 | * @return array $list[source language][target language] = true |
168 | * @throws TranslationWebServiceException |
169 | * @throws TranslationWebServiceConfigurationException |
170 | */ |
171 | abstract protected function doPairs(): array; |
172 | |
173 | /** |
174 | * Get the query. See getQueries for the public method. |
175 | * @param string $text Text to translate. |
176 | * @param string $sourceLanguage Language code of the text, as used by the service. |
177 | * @param string $targetLanguage Language code of the translation, as used by the service. |
178 | * @since 2015.02 |
179 | * @throws TranslationWebServiceException |
180 | * @throws TranslationWebServiceConfigurationException |
181 | * @throws TranslationWebServiceInvalidInputException |
182 | */ |
183 | abstract protected function getQuery( |
184 | string $text, string $sourceLanguage, string $targetLanguage |
185 | ): TranslationQuery; |
186 | |
187 | /** |
188 | * Get the response. See getResultData for the public method. |
189 | * @since 2015.02 |
190 | * @throws TranslationWebServiceException |
191 | */ |
192 | abstract protected function parseResponse( TranslationQueryResponse $response ); |
193 | |
194 | /* Default implementation */ |
195 | |
196 | /** @var string Name of this webservice. */ |
197 | protected $service; |
198 | /** @var array */ |
199 | protected $config; |
200 | /** @var LoggerInterface */ |
201 | protected $logger; |
202 | |
203 | public function __construct( string $service, array $config ) { |
204 | $this->service = $service; |
205 | $this->config = $config; |
206 | } |
207 | |
208 | /** |
209 | * Test whether given language pair is supported by the service. |
210 | * @since 2015.12 |
211 | * @throws TranslationWebServiceConfigurationException |
212 | */ |
213 | public function isSupportedLanguagePair( string $sourceLanguage, string $targetLanguage ): bool { |
214 | $pairs = $this->getSupportedLanguagePairs(); |
215 | $from = $this->mapCode( $sourceLanguage ); |
216 | $to = $this->mapCode( $targetLanguage ); |
217 | |
218 | return isset( $pairs[$from][$to] ); |
219 | } |
220 | |
221 | /** |
222 | * @see self::doPairs |
223 | * @throws TranslationWebServiceConfigurationException |
224 | */ |
225 | protected function getSupportedLanguagePairs(): array { |
226 | $cache = ObjectCache::getInstance( CACHE_ANYTHING ); |
227 | |
228 | return $cache->getWithSetCallback( |
229 | $cache->makeKey( 'translate-tmsug-pairs-' . $this->service ), |
230 | $cache::TTL_DAY, |
231 | function ( &$ttl ) use ( $cache ) { |
232 | try { |
233 | $pairs = $this->doPairs(); |
234 | } catch ( Exception $e ) { |
235 | $pairs = []; |
236 | $this->reportTranslationServiceFailure( $e->getMessage() ); |
237 | $ttl = $cache::TTL_UNCACHEABLE; |
238 | } |
239 | |
240 | return $pairs; |
241 | } |
242 | ); |
243 | } |
244 | |
245 | /** |
246 | * Some mangling that tries to keep some parts of the message unmangled |
247 | * by the translation service. Most of them support either class=notranslate |
248 | * or translate=no. |
249 | */ |
250 | protected function wrapUntranslatable( string $text ): string { |
251 | $text = str_replace( "\n", '!N!', $text ); |
252 | $pattern = '~%[^% ]+%|\$\d|{VAR:[^}]+}|{?{(PLURAL|GRAMMAR|GENDER):[^|]+\||%(\d\$)?[sd]~'; |
253 | $wrap = '<span class="notranslate" translate="no">\0</span>'; |
254 | return preg_replace( $pattern, $wrap, $text ); |
255 | } |
256 | |
257 | /** Undo the hopyfully untouched mangling done by wrapUntranslatable. */ |
258 | protected function unwrapUntranslatable( string $text ): string { |
259 | $text = str_replace( '!N!', "\n", $text ); |
260 | $pattern = '~<span class="notranslate" translate="no">(.*?)</span>~'; |
261 | return preg_replace( $pattern, '\1', $text ); |
262 | } |
263 | |
264 | /* Failure handling and suspending */ |
265 | |
266 | public function setLogger( LoggerInterface $logger ): void { |
267 | $this->logger = $logger; |
268 | } |
269 | |
270 | /** |
271 | * @var int How many failures during failure period need to happen to |
272 | * consider the service being temporarily off-line. |
273 | */ |
274 | protected $serviceFailureCount = 5; |
275 | /** |
276 | * @var int How long after the last detected failure we clear the status and |
277 | * try again. |
278 | */ |
279 | protected $serviceFailurePeriod = 900; |
280 | |
281 | /** Checks whether the service has exceeded failure count */ |
282 | public function checkTranslationServiceFailure(): bool { |
283 | $service = $this->service; |
284 | $cache = ObjectCache::getInstance( CACHE_ANYTHING ); |
285 | |
286 | $key = $cache->makeKey( "translate-service-$service" ); |
287 | $value = $cache->get( $key ); |
288 | if ( !is_string( $value ) ) { |
289 | return false; |
290 | } |
291 | |
292 | [ $count, $failed ] = explode( '|', $value, 2 ); |
293 | $count = (int)$count; |
294 | $failed = (int)$failed; |
295 | $now = (int)wfTimestamp(); |
296 | |
297 | if ( $failed + ( 2 * $this->serviceFailurePeriod ) < $now ) { |
298 | if ( $count >= $this->serviceFailureCount ) { |
299 | $this->logger->warning( "Translation service $service (was) restored" ); |
300 | } |
301 | $cache->delete( $key ); |
302 | |
303 | return false; |
304 | } elseif ( $failed + $this->serviceFailurePeriod < $now ) { |
305 | /* We are in suspicious mode and one failure is enough to update |
306 | * failed timestamp. If the service works however, let's use it. |
307 | * Previous failures are forgotten after another failure period |
308 | * has passed */ |
309 | return false; |
310 | } |
311 | |
312 | // Check the failure count against the limit |
313 | return $count >= $this->serviceFailureCount; |
314 | } |
315 | |
316 | /** Increases the failure count for this service */ |
317 | protected function reportTranslationServiceFailure( string $msg ): void { |
318 | $service = $this->service; |
319 | $this->logger->warning( "Translation service $service problem: $msg" ); |
320 | |
321 | $cache = ObjectCache::getInstance( CACHE_ANYTHING ); |
322 | $key = $cache->makeKey( "translate-service-$service" ); |
323 | |
324 | $value = $cache->get( $key ); |
325 | if ( !is_string( $value ) ) { |
326 | $count = 0; |
327 | } else { |
328 | [ $count, ] = explode( '|', $value, 2 ); |
329 | } |
330 | |
331 | $count++; |
332 | $failed = wfTimestamp(); |
333 | $cache->set( |
334 | $key, |
335 | "$count|$failed", |
336 | $this->serviceFailurePeriod * 5 |
337 | ); |
338 | |
339 | if ( $count === $this->serviceFailureCount ) { |
340 | $this->logger->error( "Translation service $service suspended" ); |
341 | } elseif ( $count > $this->serviceFailureCount ) { |
342 | $this->logger->warning( "Translation service $service still suspended" ); |
343 | } |
344 | } |
345 | } |