Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
SearchTranslationsSpecialPage.php
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\TtmServer;
5
7use ErrorPageError;
8use FormatJson;
9use FormOptions;
10use Html;
15use MediaWiki\Languages\LanguageFactory;
16use MediaWiki\Languages\LanguageNameUtils;
17use Message;
20use SpecialPage;
21use Title;
23use WikiMap;
24use Xml;
25
33class SearchTranslationsSpecialPage extends SpecialPage {
35 protected $opts;
43 protected $hl = [];
48 protected $limit = 25;
50 private $ttmServerFactory;
52 private $languageFactory;
53
54 public function __construct(
55 TtmServerFactory $ttmServerFactory,
56 LanguageFactory $languageFactory
57 ) {
58 parent::__construct( 'SearchTranslations' );
59 $this->hl = [
60 Utilities::getPlaceholder(),
61 Utilities::getPlaceholder(),
62 ];
63
64 $this->ttmServerFactory = $ttmServerFactory;
65 $this->languageFactory = $languageFactory;
66 }
67
68 public function execute( $par ) {
69 global $wgLanguageCode;
70 $this->setHeaders();
71 $this->checkPermissions();
72
73 $server = $this->ttmServerFactory->getDefault();
74 if ( !$server instanceof SearchableTTMServer ) {
75 throw new ErrorPageError( 'tux-sst-nosolr-title', 'tux-sst-nosolr-body' );
76 }
77
78 $out = $this->getOutput();
79 $out->addModuleStyles( 'jquery.uls.grid' );
80 $out->addModuleStyles( 'ext.translate.specialpages.styles' );
81 $out->addModuleStyles( 'ext.translate.special.translate.styles' );
82 $out->addModuleStyles( [ 'mediawiki.ui.button', 'mediawiki.ui.input', 'mediawiki.ui.checkbox' ] );
83 $out->addModules( 'ext.translate.special.searchtranslations' );
84 $out->addModules( 'ext.translate.special.searchtranslations.operatorsuggest' );
85 $out->addHelpLink( 'Help:Extension:Translate#searching' );
86 $out->addJsConfigVars(
87 'wgTranslateLanguages',
88 Utilities::getLanguageNames( LanguageNameUtils::AUTONYMS )
89 );
90
91 $this->opts = $opts = new FormOptions();
92 $opts->add( 'query', '' );
93 $opts->add( 'sourcelanguage', $wgLanguageCode );
94 $opts->add( 'language', '' );
95 $opts->add( 'group', '' );
96 $opts->add( 'grouppath', '' );
97 $opts->add( 'filter', '' );
98 $opts->add( 'match', '' );
99 $opts->add( 'case', '' );
100 $opts->add( 'limit', $this->limit );
101 $opts->add( 'offset', 0 );
102
103 $opts->fetchValuesFromRequest( $this->getRequest() );
104
105 $queryString = $opts->getValue( 'query' );
106
107 if ( $queryString === '' ) {
108 $this->showEmptySearch();
109 return;
110 }
111
112 $search = $this->getSearchInput( $queryString );
113
114 $options = $params = $opts->getAllValues();
115 $filter = $opts->getValue( 'filter' );
116 try {
117 if ( $opts->getValue( 'language' ) === '' ) {
118 $options['language'] = $this->getLanguage()->getCode();
119 }
120 $translationSearch = new CrossLanguageTranslationSearchQuery( $options, $server );
121 if ( in_array( $filter, $translationSearch->getAvailableFilters() ) ) {
122 if ( $options['language'] === $options['sourcelanguage'] ) {
123 $this->showSearchError( $search, $this->msg( 'tux-sst-error-language' ) );
124 return;
125 }
126
127 $opts->setValue( 'language', $options['language'] );
128 $documents = $translationSearch->getDocuments();
129 $total = $translationSearch->getTotalHits();
130 $resultset = $translationSearch->getResultSet();
131 } else {
132 $resultset = $server->search( $queryString, $params, $this->hl );
133 $documents = $server->getDocuments( $resultset );
134 $total = $server->getTotalHits( $resultset );
135 }
136 } catch ( TTMServerException $e ) {
137 $message = $e->getMessage();
138 // Known exceptions
139 if ( preg_match( '/^Result window is too large/', $message ) ) {
140 $this->showSearchError( $search, $this->msg( 'tux-sst-error-offset' ) );
141 return;
142 }
143
144 // Other exceptions
145 error_log( 'Translation search server unavailable: ' . $e->getMessage() );
146 throw new ErrorPageError( 'tux-sst-solr-offline-title', 'tux-sst-solr-offline-body' );
147 }
148
149 // Part 1: facets
150 $facets = $server->getFacets( $resultset );
151 $facetHtml = '';
152
153 if ( $facets['language'] !== [] ) {
154 if ( $filter !== '' ) {
155 $facets['language'] = array_merge(
156 $facets['language'],
157 [ $opts->getValue( 'language' ) => $total ]
158 );
159 }
160 $facetHtml = Html::element( 'div',
161 [ 'class' => 'row facet languages',
162 'data-facets' => FormatJson::encode( $this->getLanguages( $facets['language'] ) ),
163 'data-language' => $opts->getValue( 'language' ),
164 ],
165 $this->msg( 'tux-sst-facet-language' )->text()
166 );
167 }
168
169 if ( $facets['group'] !== [] ) {
170 $facetHtml .= Html::element( 'div',
171 [ 'class' => 'row facet groups',
172 'data-facets' => FormatJson::encode( $this->getGroups( $facets['group'] ) ),
173 'data-group' => $opts->getValue( 'group' ) ],
174 $this->msg( 'tux-sst-facet-group' )->text()
175 );
176 }
177
178 // Part 2: results
179 $resultsHtml = '';
180
181 $title = Title::newFromText( $queryString );
182 if ( $title && !in_array( $filter, $translationSearch->getAvailableFilters() ) ) {
183 $handle = new MessageHandle( $title );
184 $code = $handle->getCode();
185 $language = $opts->getValue( 'language' );
186 if ( $code !== '' && $code !== $language && $handle->isValid() ) {
187 $dataProvider = new TranslationAidDataProvider( $handle );
188 $aid = new CurrentTranslationAid(
189 $handle->getGroup(),
190 $handle,
191 $this->getContext(),
192 $dataProvider
193 );
194 $document = [
195 'wiki' => WikiMap::getCurrentWikiId(),
196 'localid' => $handle->getTitleForBase()->getPrefixedText(),
197 'content' => $aid->getData()['value'],
198 'language' => $handle->getCode(),
199 ];
200 array_unshift( $documents, $document );
201 $total++;
202 }
203 }
204
205 foreach ( $documents as $document ) {
206 $text = $document['content'];
207 $text = Utilities::convertWhiteSpaceToHTML( $text );
208
209 list( $pre, $post ) = $this->hl;
210 $text = str_replace( $pre, '<strong class="tux-search-highlight">', $text );
211 $text = str_replace( $post, '</strong>', $text );
212
213 $title = Title::newFromText( $document['localid'] . '/' . $document['language'] );
214 if ( !$title ) {
215 // Should not ever happen but who knows...
216 continue;
217 }
218
219 $resultAttribs = [
220 'class' => 'row tux-message',
221 'data-title' => $title->getPrefixedText(),
222 'data-language' => $document['language'],
223 ];
224
225 $handle = new MessageHandle( $title );
226
227 if ( $handle->isValid() ) {
228 $uri = Utilities::getEditorUrl( $handle );
229 $link = Html::element(
230 'a',
231 [ 'href' => $uri ],
232 $this->msg( 'tux-sst-edit' )->text()
233 );
234 } else {
235 $url = wfParseUrl( $document['uri'] );
236 $domain = $url['host'];
237 $link = Html::element(
238 'a',
239 [ 'href' => $document['uri'] ],
240 $this->msg( 'tux-sst-view-foreign', $domain )->text()
241 );
242 }
243
244 $access = Html::rawElement(
245 'div',
246 [ 'class' => 'row tux-edit tux-message-item' ],
247 $link
248 );
249
250 $titleText = $title->getPrefixedText();
251 $titleAttribs = [
252 'class' => 'row tux-title',
253 'dir' => 'ltr',
254 ];
255
256 $language = $this->languageFactory->getLanguage( $document['language'] );
257 $textAttribs = [
258 'class' => 'row tux-text',
259 'lang' => $language->getHtmlCode(),
260 'dir' => $language->getDir(),
261 ];
262
263 $resultsHtml .= Html::openElement( 'div', $resultAttribs )
264 . Html::rawElement( 'div', $textAttribs, $text )
265 . Html::element( 'div', $titleAttribs, $titleText )
266 . $access
267 . Html::closeElement( 'div' );
268 }
269
270 $resultsHtml .= Html::rawElement( 'hr', [ 'class' => 'tux-pagination-line' ] );
271
272 $prev = $next = '';
273 $offset = $this->opts->getValue( 'offset' );
274 $params = $this->opts->getChangedValues();
275
276 if ( $total - $offset > $this->limit ) {
277 $newParams = [ 'offset' => $offset + $this->limit ] + $params;
278 $attribs = [
279 'class' => 'mw-ui-button pager-next',
280 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
281 ];
282 $next = Html::element( 'a', $attribs, $this->msg( 'tux-sst-next' )->text() );
283 }
284 if ( $offset ) {
285 $newParams = [ 'offset' => max( 0, $offset - $this->limit ) ] + $params;
286 $attribs = [
287 'class' => 'mw-ui-button pager-prev',
288 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
289 ];
290 $prev = Html::element( 'a', $attribs, $this->msg( 'tux-sst-prev' )->text() );
291 }
292
293 $resultsHtml .= Html::rawElement( 'div', [ 'class' => 'tux-pagination-links' ],
294 "$prev $next"
295 );
296
297 $count = $this->msg( 'tux-sst-count' )->numParams( $total )->escaped();
298
299 $this->showSearch( $search, $count, $facetHtml, $resultsHtml, $total );
300 }
301
302 private function getLanguages( array $facet ): array {
303 $output = [];
304
305 $nondefaults = $this->opts->getChangedValues();
306 $selected = $this->opts->getValue( 'language' );
307 $filter = $this->opts->getValue( 'filter' );
308
309 foreach ( $facet as $key => $value ) {
310 if ( $filter !== '' && $key === $selected ) {
311 unset( $nondefaults['language'] );
312 unset( $nondefaults['filter'] );
313 } elseif ( $filter !== '' ) {
314 $nondefaults['language'] = $key;
315 $nondefaults['filter'] = $filter;
316 } elseif ( $key === $selected ) {
317 unset( $nondefaults['language'] );
318 } else {
319 $nondefaults['language'] = $key;
320 }
321
322 $url = $this->getPageTitle()->getLocalURL( $nondefaults );
323 $value = $this->getLanguage()->formatNum( $value );
324
325 $output[$key] = [
326 'count' => $value,
327 'url' => $url
328 ];
329 }
330
331 return $output;
332 }
333
334 private function getGroups( array $facet ): array {
335 $structure = MessageGroups::getGroupStructure();
336 return $this->makeGroupFacetRows( $structure, $facet );
337 }
338
339 private function makeGroupFacetRows(
340 array $groups,
341 array $counts,
342 int $level = 0,
343 string $pathString = ''
344 ): array {
345 $output = [];
346
347 $nondefaults = $this->opts->getChangedValues();
348 $selected = $this->opts->getValue( 'group' );
349 $path = explode( '|', $this->opts->getValue( 'grouppath' ) );
350
351 foreach ( $groups as $mixed ) {
352 $subgroups = $group = $mixed;
353
354 if ( is_array( $mixed ) ) {
355 $group = array_shift( $subgroups );
356 } else {
357 $subgroups = [];
358 }
359 '@phan-var \MessageGroup $group';
360 $id = $group->getId();
361
362 if ( $id !== $selected && !isset( $counts[$id] ) ) {
363 continue;
364 }
365
366 if ( $id === $selected ) {
367 unset( $nondefaults['group'] );
368 $nondefaults['grouppath'] = $pathString;
369 } else {
370 $nondefaults['group'] = $id;
371 $nondefaults['grouppath'] = $pathString . $id;
372 }
373
374 $value = $counts[$id] ?? 0;
375
376 $output[$id] = [
377 'id' => $id,
378 'count' => $value,
379 'label' => $group->getLabel(),
380 ];
381
382 if ( isset( $path[$level] ) && $path[$level] === $id ) {
383 $output[$id]['groups'] = $this->makeGroupFacetRows(
384 $subgroups,
385 $counts,
386 $level + 1,
387 "$pathString$id|"
388 );
389 }
390 }
391
392 return $output;
393 }
394
395 private function showSearch(
396 string $search,
397 string $count,
398 string $facets,
399 string $results,
400 int $total
401 ): void {
402 $messageSelector = $this->messageSelector();
403 $this->getOutput()->addHTML(
404 <<<HTML
405 <div class="grid tux-searchpage">
406 <div class="row tux-searchboxform">
407 <div class="tux-search-tabs offset-by-three">$messageSelector</div>
408 <div class="row tux-search-options">
409 <div class="offset-by-three nine columns tux-search-inputs">
410 <div class="row searchinput">$search</div>
411 <div class="row count">$count</div>
412 </div>
413 </div>
414 </div>
415 HTML
416 );
417
418 $query = trim( $this->opts->getValue( 'query' ) );
419 $hasSpace = preg_match( '/\s/', $query );
420 $match = $this->opts->getValue( 'match' );
421 $size = 100;
422 if ( $total > $size && $match !== 'all' && $hasSpace ) {
423 $params = $this->opts->getChangedValues();
424 $params = [ 'match' => 'all' ] + $params;
425 $linkText = $this->msg( 'tux-sst-link-all-match' )->text();
426 $link = $this->getPageTitle()->getFullURL( $params );
427 $link = "<span class='plainlinks'>[$link $linkText]</span>";
428
429 $out = $this->getOutput();
430 $out->addHTML(
431 Html::successBox(
432 $out->msg( 'tux-sst-match-message', $link )->parse()
433 )
434 );
435 }
436
437 $this->getOutput()->addHTML(
438 <<<HTML
439 <div class="row searchcontent">
440 <div class="three columns facets">$facets</div>
441 <div class="nine columns results">$results</div>
442 </div>
443 </div>
444 HTML
445 );
446 }
447
448 private function showEmptySearch(): void {
449 $search = $this->getSearchInput( '' );
450 $this->getOutput()->addHTML(
451 <<<HTML
452 <div class="grid tux-searchpage">
453 <div class="row searchinput">
454 <div class="nine columns offset-by-three">$search</div>
455 </div>
456 </div>
457 HTML
458 );
459 }
460
461 private function showSearchError( string $search, Message $message ): void {
462 $messageSelector = $this->messageSelector();
463 $messageHTML = Html::errorBox(
464 $message->escaped(),
465 '',
466 'row'
467 );
468 $this->getOutput()->addHTML(
469 <<<HTML
470 <div class="grid tux-searchpage">
471 <div class="row tux-searchboxform">
472 <div class="tux-search-tabs offset-by-three">$messageSelector</div>
473 <div class="row tux-search-options">
474 <div class="offset-by-three nine columns tux-search-inputs">
475 <div class="row searchinput">$search</div>
476 $messageHTML
477 </div>
478 </div>
479 </div>
480 </div>
481 HTML
482 );
483 }
484
486 private function ellipsisSelector( string $key, string $value ): string {
487 $nondefaults = $this->opts->getChangedValues();
488 $taskParams = [ 'filter' => $value ] + $nondefaults;
489 ksort( $taskParams );
490 $href = $this->getPageTitle()->getLocalURL( $taskParams );
491 $link = Html::element( 'a',
492 [ 'href' => $href ],
493 // Messages for grepping:
494 // tux-sst-ellipsis-untranslated
495 // tux-sst-ellipsis-outdated
496 $this->msg( 'tux-sst-ellipsis-' . $key )->text()
497 );
498
499 $container = Html::rawElement( 'li', [
500 'class' => 'column',
501 'data-filter' => $value,
502 'data-title' => $key,
503 ], $link );
504
505 return $container;
506 }
507
509 private function messageSelector(): string {
510 $nondefaults = $this->opts->getChangedValues();
511 $output = Html::openElement( 'div', [ 'class' => 'row tux-messagetable-header' ] );
512 $output .= Html::openElement( 'div', [ 'class' => 'nine columns' ] );
513 $output .= Html::openElement( 'ul', [ 'class' => 'row tux-message-selector' ] );
514 $tabs = [
515 'default' => '',
516 'translated' => 'translated',
517 'untranslated' => 'untranslated'
518 ];
519
520 $ellipsisOptions = [
521 'outdated' => 'fuzzy'
522 ];
523
524 $selected = $this->opts->getValue( 'filter' );
525 if ( in_array( $selected, $ellipsisOptions ) ) {
526 $ellipsisOptions = array_slice( $tabs, -1 );
527
528 // Remove the last tab
529 array_pop( $tabs );
530 $tabs = array_merge( $tabs, [ 'outdated' => $selected ] );
531 } elseif ( !in_array( $selected, $tabs ) ) {
532 $selected = '';
533 }
534
535 $container = Html::openElement( 'ul', [ 'class' => 'column tux-message-selector' ] );
536 foreach ( $ellipsisOptions as $optKey => $optValue ) {
537 $container .= $this->ellipsisSelector( $optKey, $optValue );
538 }
539
540 $sourcelanguage = $this->opts->getValue( 'sourcelanguage' );
541 $sourcelanguage = Utilities::getLanguageName( $sourcelanguage );
542 foreach ( $tabs as $tab => $filter ) {
543 // Messages for grepping:
544 // tux-sst-default
545 // tux-sst-translated
546 // tux-sst-untranslated
547 // tux-sst-outdated
548 $tabClass = "tux-sst-$tab";
549 $taskParams = [ 'filter' => $filter ] + $nondefaults;
550 ksort( $taskParams );
551 $href = $this->getPageTitle()->getLocalURL( $taskParams );
552 if ( $tab === 'default' ) {
553 $link = Html::element(
554 'a',
555 [ 'href' => $href ],
556 $this->msg( $tabClass )->text()
557 );
558 } else {
559 $link = Html::element(
560 'a',
561 [ 'href' => $href ],
562 $this->msg( $tabClass, $sourcelanguage )->text()
563 );
564 }
565
566 if ( $selected === $filter ) {
567 $tabClass .= ' selected';
568 }
569 $output .= Html::rawElement( 'li', [
570 'class' => [ 'column', $tabClass ],
571 'data-filter' => $filter,
572 'data-title' => $tab,
573 ], $link );
574 }
575
576 // More column
577 $output .= Html::openElement( 'li', [ 'class' => 'column more' ] ) .
578 '...' .
579 $container .
580 Html::closeElement( 'li' );
581
582 $output .= Html::closeElement( 'ul' ) . Html::closeElement( 'div' ) . Html::closeElement( 'div' );
583
584 return $output;
585 }
586
587 private function getSearchInput( string $query ) {
588 $attribs = [
589 'placeholder' => $this->msg( 'tux-sst-search-ph' )->text(),
590 'class' => 'searchinputbox mw-ui-input',
591 'dir' => $this->getLanguage()->getDir(),
592 ];
593
594 $title = Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
595 $input = Xml::input( 'query', false, $query, $attribs );
596 $submit = Xml::submitButton(
597 $this->msg( 'tux-sst-search' )->text(),
598 [ 'class' => 'mw-ui-button mw-ui-progressive' ]
599 );
600
601 $nondefaults = $this->opts->getChangedValues();
602 $checkLabel = Xml::checkLabel(
603 $this->msg( 'tux-sst-case-sensitive' )->text(),
604 'case',
605 'tux-case-sensitive',
606 isset( $nondefaults['case'] )
607 );
608 $checkLabel = Html::openElement(
609 'div',
610 [ 'class' => 'tux-search-operators mw-ui-checkbox' ]
611 ) .
612 $checkLabel .
613 Html::closeElement( 'div' );
614
615 $lang = $this->getRequest()->getVal( 'language' );
616 $language = $lang === null ? '' : Html::hidden( 'language', $lang );
617
618 $form = Html::rawElement( 'form', [ 'action' => wfScript(), 'name' => 'searchform' ],
619 $title . $input . $submit . $checkLabel . $language
620 );
621
622 return $form;
623 }
624
625 protected function getGroupName() {
626 return 'translation';
627 }
628}
Factory class for accessing message groups individually by id or all of them as a list.
Translation aid that provides the current saved translation.
Essentially random collection of helper functions, similar to GlobalFunctions.php.
Definition Utilities.php:30
Class for pointing to messages, like Title class is for titles.
Interface for TTMServer that can act as backend for translation search.