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