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