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