Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
ElasticSearchTTMServer.php
Go to the documentation of this file.
1<?php
11use Elastica\Aggregation\Terms;
12use Elastica\Client;
13use Elastica\Document;
14use Elastica\Exception\ExceptionInterface;
15use Elastica\Query;
16use Elastica\Query\BoolQuery;
17use Elastica\Query\FunctionScore;
18use Elastica\Query\MatchQuery;
19use Elastica\Query\Term;
20use MediaWiki\Extension\Elastica\MWElasticUtils;
22use MediaWiki\Logger\LoggerFactory;
23use MediaWiki\MediaWikiServices;
24
31 extends TTMServer
33{
39 private const BULK_INDEX_RETRY_ATTEMPTS = 5;
40
46 private const WAIT_UNTIL_READY_TIMEOUT = 3600;
47
49 protected $client;
53 protected $logger;
57 protected $updateMapping = false;
58
59 public function isLocalSuggestion( array $suggestion ) {
60 return $suggestion['wiki'] === WikiMap::getCurrentWikiId();
61 }
62
63 public function expandLocation( array $suggestion ) {
64 return $suggestion['uri'];
65 }
66
67 public function query( $sourceLanguage, $targetLanguage, $text ) {
68 try {
69 return $this->doQuery( $sourceLanguage, $targetLanguage, $text );
70 } catch ( Exception $e ) {
71 throw new TranslationHelperException( 'Elastica exception: ' . $e );
72 }
73 }
74
75 protected function doQuery( $sourceLanguage, $targetLanguage, $text ) {
76 if ( !$this->useWikimediaExtraPlugin() ) {
77 // ElasticTTM is currently not compatible with elasticsearch 2.x/5.x
78 // It needs FuzzyLikeThis ported via the wmf extra plugin
79 throw new RuntimeException( 'The wikimedia extra plugin is mandatory.' );
80 }
81 /* Two query system:
82 * 1) Find all strings in source language that match text
83 * 2) Do another query for translations for those strings
84 */
85 $connection = $this->getClient()->getConnection();
86 $oldTimeout = $connection->getTimeout();
87 $connection->setTimeout( 10 );
88
89 $fuzzyQuery = new FuzzyLikeThis();
90 $fuzzyQuery->setLikeText( $text );
91 $fuzzyQuery->addFields( [ 'content' ] );
92
93 $boostQuery = new FunctionScore();
94 $boostQuery->addFunction(
95 'levenshtein_distance_score',
96 [
97 'text' => $text,
98 'field' => 'content'
99 ]
100 );
101 $boostQuery->setBoostMode( FunctionScore::BOOST_MODE_REPLACE );
102
103 // Wrap the fuzzy query so it can be used as a filter.
104 // This is slightly faster, as ES can throw away the scores by this query.
105 $bool = new BoolQuery();
106 $bool->addFilter( $fuzzyQuery );
107 $bool->addMust( $boostQuery );
108
109 $languageFilter = new Term();
110 $languageFilter->setTerm( 'language', $sourceLanguage );
111 $bool->addFilter( $languageFilter );
112
113 // The whole query
114 $query = new Query();
115 $query->setQuery( $bool );
116
117 // The interface usually displays three best candidates. These might
118 // come from more than three source things, if the translations are
119 // the same. In other words suggestions are grouped by the suggested
120 // translation. This algorithm might not find all suggestions, if the
121 // top N best matching source texts don't have equivalent translations
122 // in the target language, but worse matches which we did not fetch do.
123 // This code tries to balance between doing too many or too big queries
124 // and not fetching enough results to show all possible suggestions.
125 $sizeFirst = 100;
126 $sizeSecond = $sizeFirst * 5;
127
128 $query->setFrom( 0 );
129 $query->setSize( $sizeFirst );
130 $query->setParam( '_source', [ 'content' ] );
131 $cutoff = $this->config['cutoff'] ?? 0.65;
132 $query->setParam( 'min_score', $cutoff );
133 $query->setSort( [ '_score', 'wiki', 'localid' ] );
134
135 /* This query is doing two unrelated things:
136 * 1) Collect the message contents and scores so that they can
137 * be accessed later for the translations we found.
138 * 2) Build the query string for the query that fetches the translations.
139 */
140 $contents = $scores = $terms = [];
141 do {
142 $resultset = $this->getIndex()->search( $query );
143
144 if ( count( $resultset ) === 0 ) {
145 break;
146 }
147
148 foreach ( $resultset->getResults() as $result ) {
149 $data = $result->getData();
150 $score = $result->getScore();
151
152 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
153 $contents[$sourceId] = $data['content'];
154 $scores[$sourceId] = $score;
155 $terms[] = "$sourceId/$targetLanguage";
156 }
157
158 // Check if it looks like that we are hitting the long tail already.
159 // Otherwise, we'll do a query to fetch some more to reach a "sane"
160 // breaking point, i.e. include all suggestions with same content
161 // for reliable used X times statistics.
162 if ( count( array_unique( $scores ) ) > 5 ) {
163 break;
164 }
165
166 // Okay, We are now in second iteration of the loop. We already got
167 // lots of suggestions. We will give up for now even if it means we
168 // return in some sense incomplete results.
169 if ( count( $resultset ) === $sizeSecond ) {
170 break;
171 }
172
173 // After the first query, the smallest score is the new threshold.
174 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
175 $query->setParam( 'min_score', $score );
176 $query->setFrom( $query->getParam( 'size' ) + $query->getParam( 'from' ) );
177 $query->setSize( $sizeSecond );
178
179 // Break if we already got all hits
180 } while ( $resultset->getTotalHits() > count( $contents ) );
181
182 $suggestions = [];
183
184 // Skip second query if first query found nothing. Keeping only one return
185 // statement in this method to avoid forgetting to reset connection timeout
186 if ( $terms !== [] ) {
187 $idQuery = new Query\Terms( '_id', $terms );
188
189 $query = new Query( $idQuery );
190 $query->setSize( 25 );
191 $query->setParam( '_source', [ 'wiki', 'uri', 'content', 'localid' ] );
192 $resultset = $this->getIndex()->search( $query );
193
194 foreach ( $resultset->getResults() as $result ) {
195 $data = $result->getData();
196
197 // Construct the matching source id
198 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
199
200 $suggestions[] = [
201 'source' => $contents[$sourceId],
202 'target' => $data['content'],
203 'context' => $data['localid'],
204 'quality' => $scores[$sourceId],
205 'wiki' => $data['wiki'],
206 'location' => $data['localid'] . '/' . $targetLanguage,
207 'uri' => $data['uri'],
208 ];
209 }
210
211 // Ensure results are in quality order
212 uasort( $suggestions, static function ( $a, $b ) {
213 if ( $a['quality'] === $b['quality'] ) {
214 return 0;
215 }
216
217 return ( $a['quality'] < $b['quality'] ) ? 1 : -1;
218 } );
219 }
220
221 $connection->setTimeout( $oldTimeout );
222
223 return $suggestions;
224 }
225
226 /* Write functions */
227
236 public function update( MessageHandle $handle, $targetText ) {
237 if ( !$handle->isValid() || $handle->getCode() === '' ) {
238 return false;
239 }
240
241 /* There are various different cases here:
242 * [new or updated] [fuzzy|non-fuzzy] [translation|definition]
243 * 1) We don't distinguish between new or updated here.
244 * 2) Delete old translation, but not definition
245 * 3) Insert new translation or definition, if non-fuzzy
246 * The definition should never be fuzzied anyway.
247 *
248 * These only apply to known messages.
249 */
250
251 $sourceLanguage = $handle->getGroup()->getSourceLanguage();
252
253 // Do not delete definitions, because the translations are attached to that
254 if ( $handle->getCode() !== $sourceLanguage ) {
255 $localid = $handle->getTitleForBase()->getPrefixedText();
256 $this->deleteByQuery( $this->getIndex(), Query::create(
257 ( new BoolQuery() )
258 ->addFilter( new Term( [ 'wiki' => WikiMap::getCurrentWikiId() ] ) )
259 ->addFilter( new Term( [ 'language' => $handle->getCode() ] ) )
260 ->addFilter( new Term( [ 'localid' => $localid ] ) ) ) );
261 }
262
263 // If translation was made fuzzy, we do not need to add anything
264 if ( $targetText === null ) {
265 return true;
266 }
267
268 // source language is null, skip doing rest of the stuff
269 if ( $sourceLanguage === null ) {
270 return true;
271 }
272
273 $revId = $handle->getTitleForLanguage( $sourceLanguage )->getLatestRevID();
274 $doc = $this->createDocument( $handle, $targetText, $revId );
275 $fname = __METHOD__;
276
277 MWElasticUtils::withRetry( self::BULK_INDEX_RETRY_ATTEMPTS,
278 function () use ( $doc ) {
279 $this->getIndex()->addDocuments( [ $doc ] );
280 },
281 static function ( $e, $errors ) use ( $fname ) {
282 $c = get_class( $e );
283 $msg = $e->getMessage();
284 error_log( $fname . ": update failed ($c: $msg); retrying." );
285 sleep( 10 );
286 }
287 );
288
289 return true;
290 }
291
298 protected function createDocument( MessageHandle $handle, $text, $revId ) {
299 $language = $handle->getCode();
300
301 $localid = $handle->getTitleForBase()->getPrefixedText();
302 $wiki = WikiMap::getCurrentWikiId();
303 $globalid = "$wiki-$localid-$revId/$language";
304
305 $data = [
306 'wiki' => $wiki,
307 'uri' => $handle->getTitle()->getCanonicalURL(),
308 'localid' => $localid,
309 'language' => $language,
310 'content' => $text,
311 'group' => $handle->getGroupIds(),
312 ];
313
314 return new Document( $globalid, $data, '_doc' );
315 }
316
321 public function createIndex( $rebuild ) {
322 $indexSettings = [
323 'settings' => [
324 'index' => [
325 'number_of_shards' => $this->getShardCount(),
326 'analysis' => [
327 'filter' => [
328 'prefix_filter' => [
329 'type' => 'edge_ngram',
330 'min_gram' => 2,
331 'max_gram' => 20
332 ]
333 ],
334 'analyzer' => [
335 'prefix' => [
336 'type' => 'custom',
337 'tokenizer' => 'standard',
338 'filter' => [ 'lowercase', 'prefix_filter' ]
339 ],
340 'casesensitive' => [
341 'tokenizer' => 'standard'
342 ]
343 ]
344 ]
345 ],
346 ],
347 ];
348 $replicas = $this->getReplicaCount();
349 if ( strpos( $replicas, '-' ) === false ) {
350 $indexSettings['settings']['index']['number_of_replicas'] = $replicas;
351 } else {
352 $indexSettings['settings']['index']['auto_expand_replicas'] = $replicas;
353 }
354
355 $this->getIndex()->create( $indexSettings, $rebuild );
356 }
357
363 public function beginBootstrap() {
364 $this->checkElasticsearchVersion();
365 $index = $this->getIndex();
366 if ( $this->updateMapping ) {
367 $this->logOutput( 'Updating the index mappings...' );
368 $this->createIndex( true );
369 } elseif ( !$index->exists() ) {
370 $this->createIndex( false );
371 }
372
373 $settings = $index->getSettings();
374 $settings->setRefreshInterval( '-1' );
375
376 $this->deleteByQuery( $this->getIndex(), Query::create(
377 ( new Term() )->setTerm( 'wiki', WikiMap::getCurrentWikiId() ) ) );
378
379 $properties = [
380 'wiki' => [ 'type' => 'keyword' ],
381 'localid' => [ 'type' => 'keyword' ],
382 'uri' => [ 'type' => 'keyword' ],
383 'language' => [ 'type' => 'keyword' ],
384 'group' => [ 'type' => 'keyword' ],
385 'content' => [
386 'type' => 'text',
387 'fields' => [
388 'content' => [
389 'type' => 'text',
390 'term_vector' => 'yes'
391 ],
392 'prefix_complete' => [
393 'type' => 'text',
394 'analyzer' => 'prefix',
395 'search_analyzer' => 'standard',
396 'term_vector' => 'yes'
397 ],
398 'case_sensitive' => [
399 'type' => 'text',
400 'analyzer' => 'casesensitive',
401 'term_vector' => 'yes'
402 ]
403 ]
404 ],
405 ];
406 if ( $this->useElastica6() ) {
407 // Elastica 6 support
408 // @phan-suppress-next-line PhanUndeclaredClassMethod
409 $mapping = new \Elastica\Type\Mapping();
410 // @phan-suppress-next-line PhanUndeclaredMethod, PhanUndeclaredClassMethod
411 $mapping->setType( $index->getType( '_doc' ) );
412 // @phan-suppress-next-line PhanUndeclaredClassMethod
413 $mapping->setProperties( $properties );
414 // @phan-suppress-next-line PhanUndeclaredClassMethod
415 $mapping->send( [ 'include_type_name' => 'true' ] );
416 } else {
417 // Elastica 7
418 $mapping = new \Elastica\Mapping( $properties );
419 $mapping->send( $index, [ 'include_type_name' => 'false' ] );
420 }
421
422 $this->waitUntilReady();
423 }
424
425 public function beginBatch() {
426 // I hate the rule that forbids {}
427 }
428
433 public function batchInsertDefinitions( array $batch ) {
434 $lb = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
435 foreach ( $batch as $data ) {
436 $lb->addObj( $data[0]->getTitle() );
437 }
438 $lb->execute();
439
440 $this->batchInsertTranslations( $batch );
441 }
442
443 public function batchInsertTranslations( array $batch ) {
444 $docs = [];
445 foreach ( $batch as $data ) {
446 [ $handle, $sourceLanguage, $text ] = $data;
447 $revId = $handle->getTitleForLanguage( $sourceLanguage )->getLatestRevID();
448 $docs[] = $this->createDocument( $handle, $text, $revId );
449 }
450
451 MWElasticUtils::withRetry( self::BULK_INDEX_RETRY_ATTEMPTS,
452 function () use ( $docs ) {
453 $this->getIndex()->addDocuments( $docs );
454 },
455 function ( $e, $errors ) {
456 $c = get_class( $e );
457 $msg = $e->getMessage();
458 $this->logOutput( "Batch failed ($c: $msg), trying again in 10 seconds" );
459 sleep( 10 );
460 }
461 );
462 }
463
464 public function endBatch() {
465 // I hate the rule that forbids {}
466 }
467
468 public function endBootstrap() {
469 $index = $this->getIndex();
470 $index->refresh();
471 $index->forcemerge();
472 $index->getSettings()->setRefreshInterval( '5s' );
473 }
474
475 public function getClient() {
476 if ( !$this->client ) {
477 if ( isset( $this->config['config'] ) ) {
478 $this->client = new Client( $this->config['config'] );
479 } else {
480 $this->client = new Client();
481 }
482 }
483 return $this->client;
484 }
485
487 public function useWikimediaExtraPlugin() {
488 return isset( $this->config['use_wikimedia_extra'] ) && $this->config['use_wikimedia_extra'];
489 }
490
492 private function getIndexName() {
493 return $this->config['index'] ?? 'ttmserver';
494 }
495
496 public function getIndex() {
497 return $this->getClient()
498 ->getIndex( $this->getIndexName() );
499 }
500
501 protected function getShardCount() {
502 return $this->config['shards'] ?? 1;
503 }
504
505 protected function getReplicaCount() {
506 return $this->config['replicas'] ?? '0-2';
507 }
508
517 protected function getIndexHealth( $indexName ) {
518 $path = "_cluster/health/$indexName";
519 $response = $this->getClient()->request( $path );
520 if ( $response->hasError() ) {
521 throw new Exception( "Error while fetching index health status: " . $response->getError() );
522 }
523 return $response->getData();
524 }
525
540 protected function waitForGreen( $indexName, $timeout ) {
541 $startTime = time();
542 while ( ( $startTime + $timeout ) > time() ) {
543 try {
544 $response = $this->getIndexHealth( $indexName );
545 $status = $response['status'] ?? 'unknown';
546 if ( $status === 'green' ) {
547 $this->logOutput( "\tGreen!" );
548 return true;
549 }
550 $this->logOutput( "\tIndex is $status retrying..." );
551 sleep( 5 );
552 } catch ( Exception $e ) {
553 $this->logOutput( "Error while waiting for green ({$e->getMessage()}), retrying..." );
554 }
555 }
556 return false;
557 }
558
559 protected function waitUntilReady() {
560 $statuses = MWElasticUtils::waitForGreen(
561 $this->getClient(),
562 $this->getIndexName(),
563 self::WAIT_UNTIL_READY_TIMEOUT );
564 $this->logOutput( "Waiting for the index to go green..." );
565 foreach ( $statuses as $message ) {
566 $this->logOutput( $message );
567 }
568
569 if ( !$statuses->getReturn() ) {
570 die( "Timeout! Please check server logs for {$this->getIndexName()}." );
571 }
572 }
573
574 public function setLogger( $logger ) {
575 $this->logger = $logger;
576 }
577
578 // Can it get any uglier?
579 protected function logOutput( $text ) {
580 if ( $this->logger ) {
581 $this->logger->statusLine( "$text\n" );
582 }
583 }
584
589 public function setDoReIndex() {
590 $this->updateMapping = true;
591 }
592
599 protected function parseQueryString( $queryString, array $opts ) {
600 $fields = $highlights = [];
601 $terms = preg_split( '/\s+/', $queryString );
602 $match = $opts['match'];
603 $case = $opts['case'];
604
605 // Map each word in the query string with its corresponding field
606 foreach ( $terms as $term ) {
607 $prefix = strstr( $term, '*', true );
608 if ( $prefix ) {
609 // For wildcard search
610 $fields['content.prefix_complete'][] = $prefix;
611 } elseif ( $case === '1' ) {
612 // For case sensitive search
613 $fields['content.case_sensitive'][] = $term;
614 } else {
615 $fields['content'][] = $term;
616 }
617 }
618
619 // Allow searching either by message content or message id (page name
620 // without language subpage) with exact match only.
621 $searchQuery = new BoolQuery();
622 foreach ( $fields as $analyzer => $words ) {
623 foreach ( $words as $word ) {
624 $boolQuery = new BoolQuery();
625 $contentQuery = new MatchQuery();
626 $contentQuery->setFieldQuery( $analyzer, $word );
627 $boolQuery->addShould( $contentQuery );
628 $messageQuery = new Term();
629 $messageQuery->setTerm( 'localid', $word );
630 $boolQuery->addShould( $messageQuery );
631
632 if ( $match === 'all' ) {
633 $searchQuery->addMust( $boolQuery );
634 } else {
635 $searchQuery->addShould( $boolQuery );
636 }
637
638 // Fields for highlighting
639 $highlights[$analyzer] = [
640 'number_of_fragments' => 0
641 ];
642
643 // Allow searching by exact message title (page name with
644 // language subpage).
645 $title = Title::newFromText( $word );
646 if ( !$title ) {
647 continue;
648 }
649 $handle = new MessageHandle( $title );
650 if ( $handle->isValid() && $handle->getCode() !== '' ) {
651 $localid = $handle->getTitleForBase()->getPrefixedText();
652 $boolQuery = new BoolQuery();
653 $messageId = new Term();
654 $messageId->setTerm( 'localid', $localid );
655 $boolQuery->addMust( $messageId );
656 $searchQuery->addShould( $boolQuery );
657 }
658 }
659 }
660
661 return [ $searchQuery, $highlights ];
662 }
663
671 public function createSearch( $queryString, $opts, $highlight ) {
672 $query = new Query();
673
674 [ $searchQuery, $highlights ] = $this->parseQueryString( $queryString, $opts );
675 $query->setQuery( $searchQuery );
676
677 $language = new Terms( 'language' );
678 $language->setField( 'language' );
679 $language->setSize( 500 );
680 $query->addAggregation( $language );
681
682 $group = new Terms( 'group' );
683 $group->setField( 'group' );
684 // Would like to prioritize the top level groups and not show subgroups
685 // if the top group has only few hits, but that doesn't seem to be possile.
686 $group->setSize( 500 );
687 $query->addAggregation( $group );
688
689 $query->setSize( $opts['limit'] );
690 $query->setFrom( $opts['offset'] );
691
692 // BoolAnd filters are executed in sequence per document. Bool filters with
693 // multiple must clauses are executed by converting each filter into a bit
694 // field then anding them together. The latter is normally faster if either
695 // of the subfilters are reused. May not make a difference in this context.
696 $filters = new BoolQuery();
697
698 $language = $opts['language'];
699 if ( $language !== '' ) {
700 $languageFilter = new Term();
701 $languageFilter->setTerm( 'language', $language );
702 $filters->addFilter( $languageFilter );
703 }
704
705 $group = $opts['group'];
706 if ( $group !== '' ) {
707 $groupFilter = new Term();
708 $groupFilter->setTerm( 'group', $group );
709 $filters->addFilter( $groupFilter );
710 }
711
712 // Check that we have at least one filter to avoid invalid query errors.
713 if ( $language !== '' || $group !== '' ) {
714 // TODO: This seems wrong, but perhaps for aggregation purposes?
715 // should make $search a must clause and use the bool query
716 // as main.
717 $query->setPostFilter( $filters );
718 }
719
720 [ $pre, $post ] = $highlight;
721 $query->setHighlight( [
722 // The value must be an object
723 'pre_tags' => [ $pre ],
724 'post_tags' => [ $post ],
725 'fields' => $highlights,
726 ] );
727
728 return $this->getIndex()->createSearch( $query );
729 }
730
739 public function search( $queryString, $opts, $highlight ) {
740 $search = $this->createSearch( $queryString, $opts, $highlight );
741
742 try {
743 return $search->search();
744 } catch ( ExceptionInterface $e ) {
745 throw new TTMServerException( $e->getMessage() );
746 }
747 }
748
753 public function getFacets( $resultset ) {
754 $aggs = $resultset->getAggregations();
755 '@phan-var array[][][] $aggs';
756
757 $ret = [
758 'language' => [],
759 'group' => []
760 ];
761
762 foreach ( $aggs as $type => $info ) {
763 foreach ( $info['buckets'] as $row ) {
764 $ret[$type][$row['key']] = $row['doc_count'];
765 }
766 }
767
768 return $ret;
769 }
770
775 public function getTotalHits( $resultset ) {
776 return $resultset->getTotalHits();
777 }
778
783 public function getDocuments( $resultset ) {
784 $ret = [];
785 foreach ( $resultset->getResults() as $document ) {
786 $data = $document->getData();
787 $hl = $document->getHighlights();
788 if ( isset( $hl['content.prefix_complete'][0] ) ) {
789 $data['content'] = $hl['content.prefix_complete'][0];
790 } elseif ( isset( $hl['content.case_sensitive'][0] ) ) {
791 $data['content'] = $hl['content.case_sensitive'][0];
792 } elseif ( isset( $hl['content'][0] ) ) {
793 $data['content'] = $hl['content'][0];
794 }
795 $ret[] = $data;
796 }
797
798 return $ret;
799 }
800
810 private function deleteByQuery( \Elastica\Index $index, Query $query ) {
811 try {
812 MWElasticUtils::deleteByQuery( $index, $query, /* $allowConflicts = */ true );
813 } catch ( Exception $e ) {
814 LoggerFactory::getInstance( 'ElasticSearchTTMServer' )->error(
815 'Problem encountered during deletion.',
816 [ 'exception' => $e ]
817 );
818
819 throw new RuntimeException( "Problem encountered during deletion.\n" . $e );
820 }
821 }
822
823 /* @throws RuntimeException */
824 private function getElasticsearchVersion(): string {
825 $response = $this->getClient()->request( '' );
826 if ( !$response->isOK() ) {
827 throw new \RuntimeException( "Cannot fetch elasticsearch version: " . $response->getError() );
828 }
829
830 $result = $response->getData();
831 if ( !isset( $result['version']['number'] ) ) {
832 throw new \RuntimeException( 'Unable to determine elasticsearch version, aborting.' );
833 }
834
835 return $result[ 'version' ][ 'number' ];
836 }
837
838 private function checkElasticsearchVersion() {
839 $version = $this->getElasticsearchVersion();
840 if ( strpos( $version, '6.8' ) !== 0 && strpos( $version, '7.' ) !== 0 ) {
841 throw new \RuntimeException( "Only Elasticsearch 6.8.x and 7.x are supported. Your version: $version." );
842 }
843 }
844
845 private function useElastica6(): bool {
846 return class_exists( '\Elastica\Type' );
847 }
848}
return[ 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->getMainConfig(), $services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance( 'Translate.GroupSynchronization'), $services->get( 'Translate:MessageIndex'));}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore(new RevTagStore(), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReview'=> static function(MediaWikiServices $services):MessageGroupReview { return new MessageGroupReview($services->getDBLoadBalancer(), $services->getHookContainer());}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->getMainConfig() ->get( 'TranslateWorkflowStates') !==false);}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=$services->getMainConfig() ->get( 'TranslateMessageIndex');if(is_string( $params)) { $params=(array) $params;} $class=array_shift( $params);return new $class( $params);}, 'Translate:MessagePrefixStats'=> static function(MediaWikiServices $services):MessagePrefixStats { return new MessagePrefixStats( $services->getTitleParser());}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getDBLoadBalancer(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'));}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatableBundleStatusStore'=> static function(MediaWikiServices $services):TranslatableBundleStatusStore { return new TranslatableBundleStatusStore($services->getDBLoadBalancer() ->getConnection(DB_PRIMARY), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), $services->getDBLoadBalancer() ->getMaintenanceConnectionRef(DB_PRIMARY));}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), new RevTagStore(), $services->getDBLoadBalancer(), $services->get( 'Translate:TranslatableBundleStatusStore'));}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnectionRef(DB_REPLICA);return new TranslationStashStorage( $db);}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getDBLoadBalancer());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}]
@phpcs-require-sorted-array
TTMServer backed based on ElasticSearch.
createDocument(MessageHandle $handle, $text, $revId)
update(MessageHandle $handle, $targetText)
Add / update translations.
batchInsertTranslations(array $batch)
Called multiple times per batch if necessary.
$logger
Reference to the maintenance script to relay logging output.
query( $sourceLanguage, $targetLanguage, $text)
Fetches all relevant suggestions for given text.
endBatch()
Called before every batch (MessageGroup).
endBootstrap()
Do any cleanup, optimizing etc.
waitForGreen( $indexName, $timeout)
Wait for the index to go green.
search( $queryString, $opts, $highlight)
Search interface.
beginBootstrap()
Begin the bootstrap process.
createIndex( $rebuild)
Create index.
getIndexHealth( $indexName)
Get index health TODO: Remove this code in the future as we drop support for older versions of the El...
beginBatch()
Called before every batch (MessageGroup).
createSearch( $queryString, $opts, $highlight)
Search interface.
setDoReIndex()
Force the update of index mappings @inheritDoc.
parseQueryString( $queryString, array $opts)
Parse query string and build the search query.
isLocalSuggestion(array $suggestion)
Determines if the suggestion returned by this TTMServer comes from this wiki or any other wiki.
expandLocation(array $suggestion)
Given suggestion returned by this TTMServer, constructs fully qualified URL to the location of the tr...
Fuzzy Like This query.
Translation helpers can throw this exception when they cannot do anything useful with the current mes...
Class for pointing to messages, like Title class is for titles.
getGroup()
Get the primary MessageGroup this message belongs to.
getTitleForLanguage( $code)
Get the original title.
isValid()
Checks if the handle corresponds to a known message.
getGroupIds()
Returns all message group ids this message belongs to.
getTitle()
Get the original title.
getCode()
Returns the language code.
getTitleForBase()
Get the title for the page base.
Some general static methods for instantiating TTMServer and helpers.
Definition TTMServer.php:20
Interface for TTMServer that can be queried (=all of them).
Interface for TTMServer that can act as backend for translation search.
Interface for TTMServer that can be updated.