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 Elastica\ResultSet;
21use MediaWiki\Extension\Elastica\MWElasticUtils;
26use MediaWiki\Logger\LoggerFactory;
27use MediaWiki\MediaWikiServices;
28
35 extends TTMServer
37{
43 private const BULK_INDEX_RETRY_ATTEMPTS = 5;
44
50 private const WAIT_UNTIL_READY_TIMEOUT = 3600;
51
53 protected $client;
57 protected $logger;
61 protected $updateMapping = false;
62
63 public function isLocalSuggestion( array $suggestion ): bool {
64 return $suggestion['wiki'] === WikiMap::getCurrentWikiId();
65 }
66
67 public function expandLocation( array $suggestion ): string {
68 return $suggestion['uri'];
69 }
70
71 public function query( string $sourceLanguage, string $targetLanguage, string $text ): array {
72 try {
73 return $this->doQuery( $sourceLanguage, $targetLanguage, $text );
74 } catch ( Exception $e ) {
75 throw new TranslationHelperException( 'Elastica exception: ' . $e );
76 }
77 }
78
79 protected function doQuery( $sourceLanguage, $targetLanguage, $text ) {
80 if ( !$this->useWikimediaExtraPlugin() ) {
81 // ElasticTTM is currently not compatible with elasticsearch 2.x/5.x
82 // It needs FuzzyLikeThis ported via the wmf extra plugin
83 throw new RuntimeException( 'The wikimedia extra plugin is mandatory.' );
84 }
85 /* Two query system:
86 * 1) Find all strings in source language that match text
87 * 2) Do another query for translations for those strings
88 */
89 $connection = $this->getClient()->getConnection();
90 $oldTimeout = $connection->getTimeout();
91 $connection->setTimeout( 10 );
92
93 $fuzzyQuery = new FuzzyLikeThis();
94 $fuzzyQuery->setLikeText( $text );
95 $fuzzyQuery->addFields( [ 'content' ] );
96
97 $boostQuery = new FunctionScore();
98 $boostQuery->addFunction(
99 'levenshtein_distance_score',
100 [
101 'text' => $text,
102 'field' => 'content'
103 ]
104 );
105 $boostQuery->setBoostMode( FunctionScore::BOOST_MODE_REPLACE );
106
107 // Wrap the fuzzy query so it can be used as a filter.
108 // This is slightly faster, as ES can throw away the scores by this query.
109 $bool = new BoolQuery();
110 $bool->addFilter( $fuzzyQuery );
111 $bool->addMust( $boostQuery );
112
113 $languageFilter = new Term();
114 $languageFilter->setTerm( 'language', $sourceLanguage );
115 $bool->addFilter( $languageFilter );
116
117 // The whole query
118 $query = new Query();
119 $query->setQuery( $bool );
120
121 // The interface usually displays three best candidates. These might
122 // come from more than three source things, if the translations are
123 // the same. In other words suggestions are grouped by the suggested
124 // translation. This algorithm might not find all suggestions, if the
125 // top N best matching source texts don't have equivalent translations
126 // in the target language, but worse matches which we did not fetch do.
127 // This code tries to balance between doing too many or too big queries
128 // and not fetching enough results to show all possible suggestions.
129 $sizeFirst = 100;
130 $sizeSecond = $sizeFirst * 5;
131
132 $query->setFrom( 0 );
133 $query->setSize( $sizeFirst );
134 $query->setParam( '_source', [ 'content' ] );
135 $cutoff = $this->config['cutoff'] ?? 0.65;
136 $query->setParam( 'min_score', $cutoff );
137 $query->setSort( [ '_score', 'wiki', 'localid' ] );
138
139 /* This query is doing two unrelated things:
140 * 1) Collect the message contents and scores so that they can
141 * be accessed later for the translations we found.
142 * 2) Build the query string for the query that fetches the translations.
143 */
144 $contents = $scores = $terms = [];
145 do {
146 $resultset = $this->getIndex()->search( $query );
147
148 if ( count( $resultset ) === 0 ) {
149 break;
150 }
151
152 foreach ( $resultset->getResults() as $result ) {
153 $data = $result->getData();
154 $score = $result->getScore();
155
156 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
157 $contents[$sourceId] = $data['content'];
158 $scores[$sourceId] = $score;
159 $terms[] = "$sourceId/$targetLanguage";
160 }
161
162 // Check if it looks like that we are hitting the long tail already.
163 // Otherwise, we'll do a query to fetch some more to reach a "sane"
164 // breaking point, i.e. include all suggestions with same content
165 // for reliable used X times statistics.
166 if ( count( array_unique( $scores ) ) > 5 ) {
167 break;
168 }
169
170 // Okay, We are now in second iteration of the loop. We already got
171 // lots of suggestions. We will give up for now even if it means we
172 // return in some sense incomplete results.
173 if ( count( $resultset ) === $sizeSecond ) {
174 break;
175 }
176
177 // After the first query, the smallest score is the new threshold.
178 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
179 $query->setParam( 'min_score', $score );
180 $query->setFrom( $query->getParam( 'size' ) + $query->getParam( 'from' ) );
181 $query->setSize( $sizeSecond );
182
183 // Break if we already got all hits
184 } while ( $resultset->getTotalHits() > count( $contents ) );
185
186 $suggestions = [];
187
188 // Skip second query if first query found nothing. Keeping only one return
189 // statement in this method to avoid forgetting to reset connection timeout
190 if ( $terms !== [] ) {
191 $idQuery = new Query\Terms( '_id', $terms );
192
193 $query = new Query( $idQuery );
194 $query->setSize( 25 );
195 $query->setParam( '_source', [ 'wiki', 'uri', 'content', 'localid' ] );
196 $resultset = $this->getIndex()->search( $query );
197
198 foreach ( $resultset->getResults() as $result ) {
199 $data = $result->getData();
200
201 // Construct the matching source id
202 $sourceId = preg_replace( '~/[^/]+$~', '', $result->getId() );
203
204 $suggestions[] = [
205 'source' => $contents[$sourceId],
206 'target' => $data['content'],
207 'context' => $data['localid'],
208 'quality' => $scores[$sourceId],
209 'wiki' => $data['wiki'],
210 'location' => $data['localid'] . '/' . $targetLanguage,
211 'uri' => $data['uri'],
212 ];
213 }
214
215 // Ensure results are in quality order
216 uasort( $suggestions, static function ( $a, $b ) {
217 if ( $a['quality'] === $b['quality'] ) {
218 return 0;
219 }
220
221 return ( $a['quality'] < $b['quality'] ) ? 1 : -1;
222 } );
223 }
224
225 $connection->setTimeout( $oldTimeout );
226
227 return $suggestions;
228 }
229
230 /* Write functions */
231
232 public function update( MessageHandle $handle, ?string $targetText ): bool {
233 if ( !$handle->isValid() || $handle->getCode() === '' ) {
234 return false;
235 }
236
237 /* There are various different cases here:
238 * [new or updated] [fuzzy|non-fuzzy] [translation|definition]
239 * 1) We don't distinguish between new or updated here.
240 * 2) Delete old translation, but not definition
241 * 3) Insert new translation or definition, if non-fuzzy
242 * The definition should never be fuzzied anyway.
243 *
244 * These only apply to known messages.
245 */
246
247 $sourceLanguage = $handle->getGroup()->getSourceLanguage();
248
249 // Do not delete definitions, because the translations are attached to that
250 if ( $handle->getCode() !== $sourceLanguage ) {
251 $localid = $handle->getTitleForBase()->getPrefixedText();
252 $this->deleteByQuery( $this->getIndex(), Query::create(
253 ( new BoolQuery() )
254 ->addFilter( new Term( [ 'wiki' => WikiMap::getCurrentWikiId() ] ) )
255 ->addFilter( new Term( [ 'language' => $handle->getCode() ] ) )
256 ->addFilter( new Term( [ 'localid' => $localid ] ) ) ) );
257 }
258
259 // If translation was made fuzzy, we do not need to add anything
260 if ( $targetText === null ) {
261 return true;
262 }
263
264 // source language is null, skip doing rest of the stuff
265 if ( $sourceLanguage === null ) {
266 return true;
267 }
268
269 $revId = $handle->getTitleForLanguage( $sourceLanguage )->getLatestRevID();
270 $doc = $this->createDocument( $handle, $targetText, $revId );
271 $fname = __METHOD__;
272
273 MWElasticUtils::withRetry( self::BULK_INDEX_RETRY_ATTEMPTS,
274 function () use ( $doc ) {
275 $this->getIndex()->addDocuments( [ $doc ] );
276 },
277 static function ( $e, $errors ) use ( $fname ) {
278 $c = get_class( $e );
279 $msg = $e->getMessage();
280 error_log( $fname . ": update failed ($c: $msg); retrying." );
281 sleep( 10 );
282 }
283 );
284
285 return true;
286 }
287
294 protected function createDocument( MessageHandle $handle, $text, $revId ) {
295 $language = $handle->getCode();
296
297 $localid = $handle->getTitleForBase()->getPrefixedText();
298 $wiki = WikiMap::getCurrentWikiId();
299 $globalid = "$wiki-$localid-$revId/$language";
300
301 $data = [
302 'wiki' => $wiki,
303 'uri' => $handle->getTitle()->getCanonicalURL(),
304 'localid' => $localid,
305 'language' => $language,
306 'content' => $text,
307 'group' => $handle->getGroupIds(),
308 ];
309
310 return new Document( $globalid, $data, '_doc' );
311 }
312
317 public function createIndex( $rebuild ) {
318 $indexSettings = [
319 'settings' => [
320 'index' => [
321 'number_of_shards' => $this->getShardCount(),
322 'analysis' => [
323 'filter' => [
324 'prefix_filter' => [
325 'type' => 'edge_ngram',
326 'min_gram' => 2,
327 'max_gram' => 20
328 ]
329 ],
330 'analyzer' => [
331 'prefix' => [
332 'type' => 'custom',
333 'tokenizer' => 'standard',
334 'filter' => [ 'lowercase', 'prefix_filter' ]
335 ],
336 'casesensitive' => [
337 'tokenizer' => 'standard'
338 ]
339 ]
340 ]
341 ],
342 ],
343 ];
344 $replicas = $this->getReplicaCount();
345 $key = str_contains( $replicas, '-' ) ? 'auto_expand_replicas' : 'number_of_replicas';
346 $indexSettings['settings']['index'][$key] = $replicas;
347
348 $this->getIndex()->create( $indexSettings, $rebuild );
349 }
350
356 public function beginBootstrap(): void {
357 $this->checkElasticsearchVersion();
358 $index = $this->getIndex();
359 if ( $this->updateMapping ) {
360 $this->logOutput( 'Updating the index mappings...' );
361 $this->createIndex( true );
362 } elseif ( !$index->exists() ) {
363 $this->createIndex( false );
364 }
365
366 $settings = $index->getSettings();
367 $settings->setRefreshInterval( '-1' );
368
369 $this->deleteByQuery( $this->getIndex(), Query::create(
370 ( new Term() )->setTerm( 'wiki', WikiMap::getCurrentWikiId() ) ) );
371
372 $properties = [
373 'wiki' => [ 'type' => 'keyword' ],
374 'localid' => [ 'type' => 'keyword' ],
375 'uri' => [ 'type' => 'keyword' ],
376 'language' => [ 'type' => 'keyword' ],
377 'group' => [ 'type' => 'keyword' ],
378 'content' => [
379 'type' => 'text',
380 'fields' => [
381 'content' => [
382 'type' => 'text',
383 'term_vector' => 'yes'
384 ],
385 'prefix_complete' => [
386 'type' => 'text',
387 'analyzer' => 'prefix',
388 'search_analyzer' => 'standard',
389 'term_vector' => 'yes'
390 ],
391 'case_sensitive' => [
392 'type' => 'text',
393 'analyzer' => 'casesensitive',
394 'term_vector' => 'yes'
395 ]
396 ]
397 ],
398 ];
399 if ( $this->useElastica6() ) {
400 // Elastica 6 support
401 // @phan-suppress-next-line PhanUndeclaredClassMethod
402 $mapping = new \Elastica\Type\Mapping();
403 // @phan-suppress-next-line PhanUndeclaredMethod, PhanUndeclaredClassMethod
404 $mapping->setType( $index->getType( '_doc' ) );
405 // @phan-suppress-next-line PhanUndeclaredClassMethod
406 $mapping->setProperties( $properties );
407 // @phan-suppress-next-line PhanUndeclaredClassMethod
408 $mapping->send( [ 'include_type_name' => 'true' ] );
409 } else {
410 // Elastica 7
411 $mapping = new \Elastica\Mapping( $properties );
412 $mapping->send( $index, [ 'include_type_name' => 'false' ] );
413 }
414
415 $this->waitUntilReady();
416 }
417
418 public function beginBatch(): void {
419 }
420
425 public function batchInsertDefinitions( array $batch ): void {
426 $lb = MediaWikiServices::getInstance()->getLinkBatchFactory()->newLinkBatch();
427 foreach ( $batch as $data ) {
428 $lb->addObj( $data[0]->getTitle() );
429 }
430 $lb->execute();
431
432 $this->batchInsertTranslations( $batch );
433 }
434
435 public function batchInsertTranslations( array $batch ): void {
436 $docs = [];
437 foreach ( $batch as $data ) {
438 [ $handle, $sourceLanguage, $text ] = $data;
439 $revId = $handle->getTitleForLanguage( $sourceLanguage )->getLatestRevID();
440 $docs[] = $this->createDocument( $handle, $text, $revId );
441 }
442
443 MWElasticUtils::withRetry( self::BULK_INDEX_RETRY_ATTEMPTS,
444 function () use ( $docs ) {
445 $this->getIndex()->addDocuments( $docs );
446 },
447 function ( $e, $errors ) {
448 $c = get_class( $e );
449 $msg = $e->getMessage();
450 $this->logOutput( "Batch failed ($c: $msg), trying again in 10 seconds" );
451 sleep( 10 );
452 }
453 );
454 }
455
456 public function endBatch(): void {
457 }
458
459 public function endBootstrap(): void {
460 $index = $this->getIndex();
461 $index->refresh();
462 $index->forcemerge();
463 $index->getSettings()->setRefreshInterval( '5s' );
464 }
465
466 public function getClient() {
467 if ( !$this->client ) {
468 if ( isset( $this->config['config'] ) ) {
469 $this->client = new Client( $this->config['config'] );
470 } else {
471 $this->client = new Client();
472 }
473 }
474 return $this->client;
475 }
476
478 public function useWikimediaExtraPlugin() {
479 return isset( $this->config['use_wikimedia_extra'] ) && $this->config['use_wikimedia_extra'];
480 }
481
483 private function getIndexName() {
484 return $this->config['index'] ?? 'ttmserver';
485 }
486
487 public function getIndex() {
488 return $this->getClient()
489 ->getIndex( $this->getIndexName() );
490 }
491
492 protected function getShardCount() {
493 return $this->config['shards'] ?? 1;
494 }
495
496 protected function getReplicaCount() {
497 return $this->config['replicas'] ?? '0-2';
498 }
499
508 protected function getIndexHealth( $indexName ) {
509 $path = "_cluster/health/$indexName";
510 $response = $this->getClient()->request( $path );
511 if ( $response->hasError() ) {
512 throw new Exception( "Error while fetching index health status: " . $response->getError() );
513 }
514 return $response->getData();
515 }
516
531 protected function waitForGreen( $indexName, $timeout ) {
532 $startTime = time();
533 while ( ( $startTime + $timeout ) > time() ) {
534 try {
535 $response = $this->getIndexHealth( $indexName );
536 $status = $response['status'] ?? 'unknown';
537 if ( $status === 'green' ) {
538 $this->logOutput( "\tGreen!" );
539 return true;
540 }
541 $this->logOutput( "\tIndex is $status retrying..." );
542 sleep( 5 );
543 } catch ( Exception $e ) {
544 $this->logOutput( "Error while waiting for green ({$e->getMessage()}), retrying..." );
545 }
546 }
547 return false;
548 }
549
550 protected function waitUntilReady() {
551 $statuses = MWElasticUtils::waitForGreen(
552 $this->getClient(),
553 $this->getIndexName(),
554 self::WAIT_UNTIL_READY_TIMEOUT );
555 $this->logOutput( "Waiting for the index to go green..." );
556 foreach ( $statuses as $message ) {
557 $this->logOutput( $message );
558 }
559
560 if ( !$statuses->getReturn() ) {
561 die( "Timeout! Please check server logs for {$this->getIndexName()}." );
562 }
563 }
564
565 public function setLogger( $logger ) {
566 $this->logger = $logger;
567 }
568
569 // Can it get any uglier?
570 protected function logOutput( $text ) {
571 if ( $this->logger ) {
572 $this->logger->statusLine( "$text\n" );
573 }
574 }
575
576 public function setDoReIndex(): void {
577 $this->updateMapping = true;
578 }
579
586 protected function parseQueryString( $queryString, array $opts ) {
587 $fields = $highlights = [];
588 $terms = preg_split( '/\s+/', $queryString );
589 $match = $opts['match'];
590 $case = $opts['case'];
591
592 // Map each word in the query string with its corresponding field
593 foreach ( $terms as $term ) {
594 $prefix = strstr( $term, '*', true );
595 if ( $prefix ) {
596 // For wildcard search
597 $fields['content.prefix_complete'][] = $prefix;
598 } elseif ( $case === '1' ) {
599 // For case sensitive search
600 $fields['content.case_sensitive'][] = $term;
601 } else {
602 $fields['content'][] = $term;
603 }
604 }
605
606 // Allow searching either by message content or message id (page name
607 // without language subpage) with exact match only.
608 $searchQuery = new BoolQuery();
609 foreach ( $fields as $analyzer => $words ) {
610 foreach ( $words as $word ) {
611 $boolQuery = new BoolQuery();
612 $contentQuery = new MatchQuery();
613 $contentQuery->setFieldQuery( $analyzer, $word );
614 $boolQuery->addShould( $contentQuery );
615 $messageQuery = new Term();
616 $messageQuery->setTerm( 'localid', $word );
617 $boolQuery->addShould( $messageQuery );
618
619 if ( $match === 'all' ) {
620 $searchQuery->addMust( $boolQuery );
621 } else {
622 $searchQuery->addShould( $boolQuery );
623 }
624
625 // Fields for highlighting
626 $highlights[$analyzer] = [
627 'number_of_fragments' => 0
628 ];
629
630 // Allow searching by exact message title (page name with
631 // language subpage).
632 $title = Title::newFromText( $word );
633 if ( !$title ) {
634 continue;
635 }
636 $handle = new MessageHandle( $title );
637 if ( $handle->isValid() && $handle->getCode() !== '' ) {
638 $localid = $handle->getTitleForBase()->getPrefixedText();
639 $boolQuery = new BoolQuery();
640 $messageId = new Term();
641 $messageId->setTerm( 'localid', $localid );
642 $boolQuery->addMust( $messageId );
643 $searchQuery->addShould( $boolQuery );
644 }
645 }
646 }
647
648 return [ $searchQuery, $highlights ];
649 }
650
658 public function createSearch( $queryString, $opts, $highlight ) {
659 $query = new Query();
660
661 [ $searchQuery, $highlights ] = $this->parseQueryString( $queryString, $opts );
662 $query->setQuery( $searchQuery );
663
664 $language = new Terms( 'language' );
665 $language->setField( 'language' );
666 $language->setSize( 500 );
667 $query->addAggregation( $language );
668
669 $group = new Terms( 'group' );
670 $group->setField( 'group' );
671 // Would like to prioritize the top level groups and not show subgroups
672 // if the top group has only few hits, but that doesn't seem to be possile.
673 $group->setSize( 500 );
674 $query->addAggregation( $group );
675
676 $query->setSize( $opts['limit'] );
677 $query->setFrom( $opts['offset'] );
678
679 // BoolAnd filters are executed in sequence per document. Bool filters with
680 // multiple must clauses are executed by converting each filter into a bit
681 // field then anding them together. The latter is normally faster if either
682 // of the subfilters are reused. May not make a difference in this context.
683 $filters = new BoolQuery();
684
685 $language = $opts['language'];
686 if ( $language !== '' ) {
687 $languageFilter = new Term();
688 $languageFilter->setTerm( 'language', $language );
689 $filters->addFilter( $languageFilter );
690 }
691
692 $group = $opts['group'];
693 if ( $group !== '' ) {
694 $groupFilter = new Term();
695 $groupFilter->setTerm( 'group', $group );
696 $filters->addFilter( $groupFilter );
697 }
698
699 // Check that we have at least one filter to avoid invalid query errors.
700 if ( $language !== '' || $group !== '' ) {
701 // TODO: This seems wrong, but perhaps for aggregation purposes?
702 // should make $search a must clause and use the bool query
703 // as main.
704 $query->setPostFilter( $filters );
705 }
706
707 [ $pre, $post ] = $highlight;
708 $query->setHighlight( [
709 // The value must be an object
710 'pre_tags' => [ $pre ],
711 'post_tags' => [ $post ],
712 'fields' => $highlights,
713 ] );
714
715 return $this->getIndex()->createSearch( $query );
716 }
717
726 public function search( $queryString, $opts, $highlight ) {
727 $search = $this->createSearch( $queryString, $opts, $highlight );
728
729 try {
730 return $search->search();
731 } catch ( ExceptionInterface $e ) {
732 throw new TTMServerException( $e->getMessage() );
733 }
734 }
735
737 public function getFacets( $resultset ): array {
738 $this->assertResultSetInstance( $resultset );
739 $aggs = $resultset->getAggregations();
740 '@phan-var array[][][] $aggs';
741
742 $ret = [
743 'language' => [],
744 'group' => []
745 ];
746
747 foreach ( $aggs as $type => $info ) {
748 foreach ( $info['buckets'] as $row ) {
749 $ret[$type][$row['key']] = $row['doc_count'];
750 }
751 }
752
753 return $ret;
754 }
755
757 public function getTotalHits( $resultset ): int {
758 $this->assertResultSetInstance( $resultset );
759 return $resultset->getTotalHits();
760 }
761
763 public function getDocuments( $resultset ): array {
764 $this->assertResultSetInstance( $resultset );
765 $ret = [];
766 foreach ( $resultset->getResults() as $document ) {
767 $data = $document->getData();
768 $hl = $document->getHighlights();
769 if ( isset( $hl['content.prefix_complete'][0] ) ) {
770 $data['content'] = $hl['content.prefix_complete'][0];
771 } elseif ( isset( $hl['content.case_sensitive'][0] ) ) {
772 $data['content'] = $hl['content.case_sensitive'][0];
773 } elseif ( isset( $hl['content'][0] ) ) {
774 $data['content'] = $hl['content'][0];
775 }
776 $ret[] = $data;
777 }
778
779 return $ret;
780 }
781
791 private function deleteByQuery( \Elastica\Index $index, Query $query ) {
792 try {
793 MWElasticUtils::deleteByQuery( $index, $query, /* $allowConflicts = */ true );
794 } catch ( Exception $e ) {
795 LoggerFactory::getInstance( 'ElasticSearchTTMServer' )->error(
796 'Problem encountered during deletion.',
797 [ 'exception' => $e ]
798 );
799
800 throw new RuntimeException( "Problem encountered during deletion.\n" . $e );
801 }
802 }
803
804 /* @throws RuntimeException */
805 private function getElasticsearchVersion(): string {
806 $response = $this->getClient()->request( '' );
807 if ( !$response->isOK() ) {
808 throw new \RuntimeException( "Cannot fetch elasticsearch version: " . $response->getError() );
809 }
810
811 $result = $response->getData();
812 if ( !isset( $result['version']['number'] ) ) {
813 throw new \RuntimeException( 'Unable to determine elasticsearch version, aborting.' );
814 }
815
816 return $result[ 'version' ][ 'number' ];
817 }
818
819 private function checkElasticsearchVersion() {
820 $version = $this->getElasticsearchVersion();
821 if ( !str_starts_with( $version, '6.8' ) && !str_starts_with( $version, '7.' ) ) {
822 throw new \RuntimeException( "Only Elasticsearch 6.8.x and 7.x are supported. Your version: $version." );
823 }
824 }
825
826 private function useElastica6(): bool {
827 return class_exists( '\Elastica\Type' );
828 }
829
830 private function assertResultSetInstance( $resultset ): void {
831 if ( $resultset instanceof ResultSet ) {
832 return;
833 }
834
835 throw new RuntimeException(
836 "Expected resultset to be an instance of " . ResultSet::class
837 );
838 }
839}
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:FileFormatFactory'=> static function(MediaWikiServices $services):FileFormatFactory { return new FileFormatFactory( $services->getObjectFactory());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:HookRunner'=> static function(MediaWikiServices $services):HookRunner { return new HookRunner( $services->getHookContainer());}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore($services->get( 'Translate:RevTagStore'), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReviewStore'=> static function(MediaWikiServices $services):MessageGroupReviewStore { return new MessageGroupReviewStore($services->getDBLoadBalancer(), $services->get( 'Translate:HookRunner'));}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->get( 'Translate:MessageGroupReviewStore'), $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:RevTagStore'=> static function(MediaWikiServices $services):RevTagStore { return new RevTagStore($services->getDBLoadBalancerFactory());}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleExporter'=> static function(MediaWikiServices $services):TranslatableBundleExporter { return new TranslatableBundleExporter($services->get( 'Translate:SubpageListBuilder'), $services->getWikiExporterFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleImporter'=> static function(MediaWikiServices $services):TranslatableBundleImporter { return new TranslatableBundleImporter($services->getWikiImporterFactory(), $services->get( 'Translate:TranslatablePageParser'), $services->getRevisionLookup());}, '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(), $services->get( 'Translate:RevTagStore'), $services->getDBLoadBalancer(), $services->get( 'Translate:TranslatableBundleStatusStore'), $services->get( 'Translate:TranslatablePageParser'),);}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnection(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(), $services->getDBLoadBalancer());}, '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)
batchInsertTranslations(array $batch)
Called multiple times per batch if necessary.
$logger
Reference to the maintenance script to relay logging output.
getFacets( $resultset)
@inheritDoc
endBatch()
Called after every batch (MessageGroup).
waitForGreen( $indexName, $timeout)
Wait for the index to go green.
search( $queryString, $opts, $highlight)
Search interface.
update(MessageHandle $handle, ?string $targetText)
Shovels the new translation into translation memory.
beginBootstrap()
Begin the bootstrap process.
createIndex( $rebuild)
Create index.
getTotalHits( $resultset)
@inheritDoc
getDocuments( $resultset)
@inheritDoc
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()
Instruct the service to fully wipe the index and start from scratch.
query(string $sourceLanguage, string $targetLanguage, string $text)
Fetches all relevant suggestions for given text.
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.
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:19
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.