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;
23
30 extends TTMServer
32{
38 private const BULK_INDEX_RETRY_ATTEMPTS = 5;
39
45 private const WAIT_UNTIL_READY_TIMEOUT = 3600;
46
48 protected $client;
52 protected $logger;
56 protected $updateMapping = false;
57
58 public function isLocalSuggestion( array $suggestion ) {
59 return $suggestion['wiki'] === WikiMap::getCurrentWikiId();
60 }
61
62 public function expandLocation( array $suggestion ) {
63 return $suggestion['uri'];
64 }
65
66 public function query( $sourceLanguage, $targetLanguage, $text ) {
67 try {
68 return $this->doQuery( $sourceLanguage, $targetLanguage, $text );
69 } catch ( Exception $e ) {
70 throw new TranslationHelperException( 'Elastica exception: ' . $e );
71 }
72 }
73
74 protected function doQuery( $sourceLanguage, $targetLanguage, $text ) {
75 if ( !$this->useWikimediaExtraPlugin() ) {
76 // ElasticTTM is currently not compatible with elasticsearch 2.x/5.x
77 // It needs FuzzyLikeThis ported via the wmf extra plugin
78 throw new RuntimeException( 'The wikimedia extra plugin is mandatory.' );
79 }
80 /* Two query system:
81 * 1) Find all strings in source language that match text
82 * 2) Do another query for translations for those strings
83 */
84 $connection = $this->getClient()->getConnection();
85 $oldTimeout = $connection->getTimeout();
86 $connection->setTimeout( 10 );
87
88 $fuzzyQuery = new FuzzyLikeThis();
89 $fuzzyQuery->setLikeText( $text );
90 $fuzzyQuery->addFields( [ 'content' ] );
91
92 $boostQuery = new FunctionScore();
93 $boostQuery->addFunction(
94 'levenshtein_distance_score',
95 [
96 'text' => $text,
97 'field' => 'content'
98 ]
99 );
100 $boostQuery->setBoostMode( FunctionScore::BOOST_MODE_REPLACE );
101
102 // Wrap the fuzzy query so it can be used as a filter.
103 // This is slightly faster, as ES can throw away the scores by this query.
104 $bool = new BoolQuery();
105 $bool->addFilter( $fuzzyQuery );
106 $bool->addMust( $boostQuery );
107
108 $languageFilter = new Term();
109 $languageFilter->setTerm( 'language', $sourceLanguage );
110 $bool->addFilter( $languageFilter );
111
112 // The whole query
113 $query = new Query();
114 $query->setQuery( $bool );
115
116 // The interface usually displays three best candidates. These might
117 // come from more than three source things, if the translations are
118 // the same. In other words suggestions are grouped by the suggested
119 // translation. This algorithm might not find all suggestions, if the
120 // top N best matching source texts don't have equivalent translations
121 // in the target language, but worse matches which we did not fetch do.
122 // This code tries to balance between doing too many or too big queries
123 // and not fetching enough results to show all possible suggestions.
124 $sizeFirst = 100;
125 $sizeSecond = $sizeFirst * 5;
126
127 $query->setFrom( 0 );
128 $query->setSize( $sizeFirst );
129 $query->setParam( '_source', [ 'content' ] );
130 $cutoff = $this->config['cutoff'] ?? 0.65;
131 $query->setParam( 'min_score', $cutoff );
132 $query->setSort( [ '_score', 'wiki', 'localid' ] );
133
134 /* This query is doing two unrelated things:
135 * 1) Collect the message contents and scores so that they can
136 * be accessed later for the translations we found.
137 * 2) Build the query string for the query that fetches the translations.
138 */
139 $contents = $scores = $terms = [];
140 do {
141 $resultset = $this->getIndex()->search( $query );
142
143 if ( count( $resultset ) === 0 ) {
144 break;
145 }
146
147 foreach ( $resultset->getResults() as $result ) {
148 $data = $result->getData();
149 $score = $result->getScore();
150
151 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
152 $contents[$sourceId] = $data['content'];
153 $scores[$sourceId] = $score;
154 $terms[] = "$sourceId/$targetLanguage";
155 }
156
157 // Check if it looks like that we are hitting the long tail already.
158 // Otherwise, we'll do a query to fetch some more to reach a "sane"
159 // breaking point, i.e. include all suggestions with same content
160 // for reliable used X times statistics.
161 if ( count( array_unique( $scores ) ) > 5 ) {
162 break;
163 }
164
165 // Okay, We are now in second iteration of the loop. We already got
166 // lots of suggestions. We will give up for now even if it means we
167 // return in some sense incomplete results.
168 if ( count( $resultset ) === $sizeSecond ) {
169 break;
170 }
171
172 // After the first query, the smallest score is the new threshold.
173 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
174 $query->setParam( 'min_score', $score );
175 $query->setFrom( $query->getParam( 'size' ) + $query->getParam( 'from' ) );
176 $query->setSize( $sizeSecond );
177
178 // Break if we already got all hits
179 } while ( $resultset->getTotalHits() > count( $contents ) );
180
181 $suggestions = [];
182
183 // Skip second query if first query found nothing. Keeping only one return
184 // statement in this method to avoid forgetting to reset connection timeout
185 if ( $terms !== [] ) {
186 $idQuery = new Query\Terms( '_id', $terms );
187
188 $query = new Query( $idQuery );
189 $query->setSize( 25 );
190 $query->setParam( '_source', [ 'wiki', 'uri', 'content', 'localid' ] );
191 $resultset = $this->getIndex()->search( $query );
192
193 foreach ( $resultset->getResults() as $result ) {
194 $data = $result->getData();
195
196 // Construct the matching source id
197 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
198
199 $suggestions[] = [
200 'source' => $contents[$sourceId],
201 'target' => $data['content'],
202 'context' => $data['localid'],
203 'quality' => $scores[$sourceId],
204 'wiki' => $data['wiki'],
205 'location' => $data['localid'] . '/' . $targetLanguage,
206 'uri' => $data['uri'],
207 ];
208 }
209
210 // Ensure results are in quality order
211 uasort( $suggestions, static function ( $a, $b ) {
212 if ( $a['quality'] === $b['quality'] ) {
213 return 0;
214 }
215
216 return ( $a['quality'] < $b['quality'] ) ? 1 : -1;
217 } );
218 }
219
220 $connection->setTimeout( $oldTimeout );
221
222 return $suggestions;
223 }
224
225 /* Write functions */
226
235 public function update( MessageHandle $handle, $targetText ) {
236 if ( !$handle->isValid() || $handle->getCode() === '' ) {
237 return false;
238 }
239
240 /* There are various different cases here:
241 * [new or updated] [fuzzy|non-fuzzy] [translation|definition]
242 * 1) We don't distinguish between new or updated here.
243 * 2) Delete old translation, but not definition
244 * 3) Insert new translation or definition, if non-fuzzy
245 * The definition should never be fuzzied anyway.
246 *
247 * These only apply to known messages.
248 */
249
250 $sourceLanguage = $handle->getGroup()->getSourceLanguage();
251
252 // Do not delete definitions, because the translations are attached to that
253 if ( $handle->getCode() !== $sourceLanguage ) {
254 $localid = $handle->getTitleForBase()->getPrefixedText();
255 $this->deleteByQuery( $this->getIndex(), Query::create(
256 ( new BoolQuery() )
257 ->addFilter( new Term( [ 'wiki' => WikiMap::getCurrentWikiId() ] ) )
258 ->addFilter( new Term( [ 'language' => $handle->getCode() ] ) )
259 ->addFilter( new Term( [ 'localid' => $localid ] ) ) ) );
260 }
261
262 // If translation was made fuzzy, we do not need to add anything
263 if ( $targetText === null ) {
264 return true;
265 }
266
267 // source language is null, skip doing rest of the stuff
268 if ( $sourceLanguage === null ) {
269 return true;
270 }
271
272 $revId = $handle->getTitleForLanguage( $sourceLanguage )->getLatestRevID();
273 $doc = $this->createDocument( $handle, $targetText, $revId );
274 $fname = __METHOD__;
275
276 $mwElasticUtilsClass = $this->getMWElasticUtilsClass();
277 $mwElasticUtilsClass::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 = new LinkBatch();
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 $mwElasticUtilsClass = $this->getMWElasticUtilsClass();
452 $mwElasticUtilsClass::withRetry( self::BULK_INDEX_RETRY_ATTEMPTS,
453 function () use ( $docs ) {
454 $this->getIndex()->addDocuments( $docs );
455 },
456 function ( $e, $errors ) {
457 $c = get_class( $e );
458 $msg = $e->getMessage();
459 $this->logOutput( "Batch failed ($c: $msg), trying again in 10 seconds" );
460 sleep( 10 );
461 }
462 );
463 }
464
465 public function endBatch() {
466 // I hate the rule that forbids {}
467 }
468
469 public function endBootstrap() {
470 $index = $this->getIndex();
471 $index->refresh();
472 $index->forcemerge();
473 $index->getSettings()->setRefreshInterval( '5s' );
474 }
475
476 public function getClient() {
477 if ( !$this->client ) {
478 if ( isset( $this->config['config'] ) ) {
479 $this->client = new Client( $this->config['config'] );
480 } else {
481 $this->client = new Client();
482 }
483 }
484 return $this->client;
485 }
486
488 public function useWikimediaExtraPlugin() {
489 return isset( $this->config['use_wikimedia_extra'] ) && $this->config['use_wikimedia_extra'];
490 }
491
493 private function getIndexName() {
494 return $this->config['index'] ?? 'ttmserver';
495 }
496
497 public function getIndex() {
498 return $this->getClient()
499 ->getIndex( $this->getIndexName() );
500 }
501
502 protected function getShardCount() {
503 return $this->config['shards'] ?? 1;
504 }
505
506 protected function getReplicaCount() {
507 return $this->config['replicas'] ?? '0-2';
508 }
509
518 protected function getIndexHealth( $indexName ) {
519 $path = "_cluster/health/$indexName";
520 $response = $this->getClient()->request( $path );
521 if ( $response->hasError() ) {
522 throw new Exception( "Error while fetching index health status: " . $response->getError() );
523 }
524 return $response->getData();
525 }
526
541 protected function waitForGreen( $indexName, $timeout ) {
542 $startTime = time();
543 while ( ( $startTime + $timeout ) > time() ) {
544 try {
545 $response = $this->getIndexHealth( $indexName );
546 $status = $response['status'] ?? 'unknown';
547 if ( $status === 'green' ) {
548 $this->logOutput( "\tGreen!" );
549 return true;
550 }
551 $this->logOutput( "\tIndex is $status retrying..." );
552 sleep( 5 );
553 } catch ( Exception $e ) {
554 $this->logOutput( "Error while waiting for green ({$e->getMessage()}), retrying..." );
555 }
556 }
557 return false;
558 }
559
560 protected function waitUntilReady() {
561 $mwElasticUtilsClass = $this->getMWElasticUtilsClass();
562 $statuses = $mwElasticUtilsClass::waitForGreen(
563 $this->getClient(),
564 $this->getIndexName(),
565 self::WAIT_UNTIL_READY_TIMEOUT );
566 $this->logOutput( "Waiting for the index to go green..." );
567 foreach ( $statuses as $message ) {
568 $this->logOutput( $message );
569 }
570
571 if ( !$statuses->getReturn() ) {
572 die( "Timeout! Please check server logs for {$this->getIndexName()}." );
573 }
574 }
575
576 public function setLogger( $logger ) {
577 $this->logger = $logger;
578 }
579
580 // Can it get any uglier?
581 protected function logOutput( $text ) {
582 if ( $this->logger ) {
583 $this->logger->statusLine( "$text\n" );
584 }
585 }
586
591 public function setDoReIndex() {
592 $this->updateMapping = true;
593 }
594
601 protected function parseQueryString( $queryString, array $opts ) {
602 $fields = $highlights = [];
603 $terms = preg_split( '/\s+/', $queryString );
604 $match = $opts['match'];
605 $case = $opts['case'];
606
607 // Map each word in the query string with its corresponding field
608 foreach ( $terms as $term ) {
609 $prefix = strstr( $term, '*', true );
610 if ( $prefix ) {
611 // For wildcard search
612 $fields['content.prefix_complete'][] = $prefix;
613 } elseif ( $case === '1' ) {
614 // For case sensitive search
615 $fields['content.case_sensitive'][] = $term;
616 } else {
617 $fields['content'][] = $term;
618 }
619 }
620
621 // Allow searching either by message content or message id (page name
622 // without language subpage) with exact match only.
623 $searchQuery = new BoolQuery();
624 foreach ( $fields as $analyzer => $words ) {
625 foreach ( $words as $word ) {
626 $boolQuery = new BoolQuery();
627 $contentQuery = new MatchQuery();
628 $contentQuery->setFieldQuery( $analyzer, $word );
629 $boolQuery->addShould( $contentQuery );
630 $messageQuery = new Term();
631 $messageQuery->setTerm( 'localid', $word );
632 $boolQuery->addShould( $messageQuery );
633
634 if ( $match === 'all' ) {
635 $searchQuery->addMust( $boolQuery );
636 } else {
637 $searchQuery->addShould( $boolQuery );
638 }
639
640 // Fields for highlighting
641 $highlights[$analyzer] = [
642 'number_of_fragments' => 0
643 ];
644
645 // Allow searching by exact message title (page name with
646 // language subpage).
647 $title = Title::newFromText( $word );
648 if ( !$title ) {
649 continue;
650 }
651 $handle = new MessageHandle( $title );
652 if ( $handle->isValid() && $handle->getCode() !== '' ) {
653 $localid = $handle->getTitleForBase()->getPrefixedText();
654 $boolQuery = new BoolQuery();
655 $messageId = new Term();
656 $messageId->setTerm( 'localid', $localid );
657 $boolQuery->addMust( $messageId );
658 $searchQuery->addShould( $boolQuery );
659 }
660 }
661 }
662
663 return [ $searchQuery, $highlights ];
664 }
665
673 public function createSearch( $queryString, $opts, $highlight ) {
674 $query = new Query();
675
676 [ $searchQuery, $highlights ] = $this->parseQueryString( $queryString, $opts );
677 $query->setQuery( $searchQuery );
678
679 $language = new Terms( 'language' );
680 $language->setField( 'language' );
681 $language->setSize( 500 );
682 $query->addAggregation( $language );
683
684 $group = new Terms( 'group' );
685 $group->setField( 'group' );
686 // Would like to prioritize the top level groups and not show subgroups
687 // if the top group has only few hits, but that doesn't seem to be possile.
688 $group->setSize( 500 );
689 $query->addAggregation( $group );
690
691 $query->setSize( $opts['limit'] );
692 $query->setFrom( $opts['offset'] );
693
694 // BoolAnd filters are executed in sequence per document. Bool filters with
695 // multiple must clauses are executed by converting each filter into a bit
696 // field then anding them together. The latter is normally faster if either
697 // of the subfilters are reused. May not make a difference in this context.
698 $filters = new BoolQuery();
699
700 $language = $opts['language'];
701 if ( $language !== '' ) {
702 $languageFilter = new Term();
703 $languageFilter->setTerm( 'language', $language );
704 $filters->addFilter( $languageFilter );
705 }
706
707 $group = $opts['group'];
708 if ( $group !== '' ) {
709 $groupFilter = new Term();
710 $groupFilter->setTerm( 'group', $group );
711 $filters->addFilter( $groupFilter );
712 }
713
714 // Check that we have at least one filter to avoid invalid query errors.
715 if ( $language !== '' || $group !== '' ) {
716 // TODO: This seems wrong, but perhaps for aggregation purposes?
717 // should make $search a must clause and use the bool query
718 // as main.
719 $query->setPostFilter( $filters );
720 }
721
722 [ $pre, $post ] = $highlight;
723 $query->setHighlight( [
724 // The value must be an object
725 'pre_tags' => [ $pre ],
726 'post_tags' => [ $post ],
727 'fields' => $highlights,
728 ] );
729
730 return $this->getIndex()->createSearch( $query );
731 }
732
741 public function search( $queryString, $opts, $highlight ) {
742 $search = $this->createSearch( $queryString, $opts, $highlight );
743
744 try {
745 return $search->search();
746 } catch ( ExceptionInterface $e ) {
747 throw new TTMServerException( $e->getMessage() );
748 }
749 }
750
755 public function getFacets( $resultset ) {
756 $aggs = $resultset->getAggregations();
757 '@phan-var array[][][] $aggs';
758
759 $ret = [
760 'language' => [],
761 'group' => []
762 ];
763
764 foreach ( $aggs as $type => $info ) {
765 foreach ( $info['buckets'] as $row ) {
766 $ret[$type][$row['key']] = $row['doc_count'];
767 }
768 }
769
770 return $ret;
771 }
772
777 public function getTotalHits( $resultset ) {
778 return $resultset->getTotalHits();
779 }
780
785 public function getDocuments( $resultset ) {
786 $ret = [];
787 foreach ( $resultset->getResults() as $document ) {
788 $data = $document->getData();
789 $hl = $document->getHighlights();
790 if ( isset( $hl['content.prefix_complete'][0] ) ) {
791 $data['content'] = $hl['content.prefix_complete'][0];
792 } elseif ( isset( $hl['content.case_sensitive'][0] ) ) {
793 $data['content'] = $hl['content.case_sensitive'][0];
794 } elseif ( isset( $hl['content'][0] ) ) {
795 $data['content'] = $hl['content'][0];
796 }
797 $ret[] = $data;
798 }
799
800 return $ret;
801 }
802
812 private function deleteByQuery( \Elastica\Index $index, Query $query ) {
813 try {
814 $mwElasticUtilsClass = $this->getMWElasticUtilsClass();
815 $mwElasticUtilsClass::deleteByQuery( $index, $query, /* $allowConflicts = */ true );
816 } catch ( Exception $e ) {
817 LoggerFactory::getInstance( 'ElasticSearchTTMServer' )->error(
818 'Problem encountered during deletion.',
819 [ 'exception' => $e ]
820 );
821
822 throw new RuntimeException( "Problem encountered during deletion.\n" . $e );
823 }
824 }
825
831 private function getMWElasticUtilsClass(): string {
832 if ( class_exists( MWElasticUtils::class ) ) {
833 return MWElasticUtils::class;
834 } else {
835 return '\MWElasticUtils';
836 }
837 }
838
839 /* @throws RuntimeException */
840 private function getElasticsearchVersion(): string {
841 $response = $this->getClient()->request( '' );
842 if ( !$response->isOK() ) {
843 throw new \RuntimeException( "Cannot fetch elasticsearch version: " . $response->getError() );
844 }
845
846 $result = $response->getData();
847 if ( !isset( $result['version']['number'] ) ) {
848 throw new \RuntimeException( 'Unable to determine elasticsearch version, aborting.' );
849 }
850
851 return $result[ 'version' ][ 'number' ];
852 }
853
854 private function checkElasticsearchVersion() {
855 $version = $this->getElasticsearchVersion();
856 if ( strpos( $version, '6.8' ) !== 0 && strpos( $version, '7.' ) !== 0 ) {
857 throw new \RuntimeException( "Only Elasticsearch 6.8.x and 7.x are supported. Your version: $version." );
858 }
859 }
860
861 private function useElastica6(): bool {
862 return class_exists( '\Elastica\Type' );
863 }
864}
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'), MessageIndex::singleton());}, '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: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: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: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());}, '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.