31 private FormOptions $opts;
42 private LanguageFactory $languageFactory;
43 private UrlUtils $urlUtils;
45 public function __construct(
47 LanguageFactory $languageFactory,
50 parent::__construct(
'SearchTranslations' );
52 Utilities::getPlaceholder(),
53 Utilities::getPlaceholder(),
56 $this->ttmServerFactory = $ttmServerFactory;
57 $this->languageFactory = $languageFactory;
58 $this->urlUtils = $urlUtils;
61 public function execute( $subPage ) {
62 global $wgLanguageCode;
64 $this->checkPermissions();
66 $server = $this->ttmServerFactory->getDefaultForQuerying();
68 throw new ErrorPageError(
'tux-sst-nosolr-title',
'tux-sst-nosolr-body' );
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 )
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 );
95 $opts->fetchValuesFromRequest( $this->getRequest() );
97 $queryString = $opts->getValue(
'query' );
99 if ( $queryString ===
'' ) {
100 $this->showEmptySearch();
104 $search = $this->getSearchInput( $queryString );
106 $options = $params = $opts->getAllValues();
107 $filter = $opts->getValue(
'filter' );
109 if ( $opts->getValue(
'language' ) ===
'' ) {
110 $options[
'language'] = $this->getLanguage()->getCode();
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' ) );
119 $opts->setValue(
'language', $options[
'language'] );
120 $documents = $translationSearch->getDocuments();
121 $total = $translationSearch->getTotalHits();
122 $resultSet = $translationSearch->getResultSet();
124 $resultSet = $server->search( $queryString, $params, $this->hl );
125 $documents = $server->getDocuments( $resultSet );
126 $total = $server->getTotalHits( $resultSet );
128 }
catch ( TtmServerException $e ) {
129 $message = $e->getMessage();
131 if ( preg_match(
'/^Result window is too large/', $message ) ) {
132 $this->showSearchError( $search, $this->msg(
'tux-sst-error-offset' ) );
137 error_log(
'Translation search server unavailable: ' . $e->getMessage() );
138 throw new ErrorPageError(
'tux-sst-solr-offline-title',
'tux-sst-solr-offline-body' );
142 $facets = $server->getFacets( $resultSet );
145 if ( $facets[
'language'] !== [] ) {
146 if ( $filter !==
'' ) {
147 $facets[
'language'] = array_merge(
149 [ $opts->getValue(
'language' ) => $total ]
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' ),
157 $this->msg(
'tux-sst-facet-language' )->text()
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()
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(
187 'wiki' => WikiMap::getCurrentWikiId(),
188 'localid' => $handle->getTitleForBase()->getPrefixedText(),
189 'content' => $aid->getData()[
'value'],
190 'language' => $handle->getCode(),
192 array_unshift( $documents, $document );
197 foreach ( $documents as $document ) {
198 $text = $document[
'content'];
199 if ( $text ===
null ) {
202 $text = Utilities::convertWhiteSpaceToHTML( $text );
204 [ $pre, $post ] = $this->hl;
205 $text = str_replace( $pre,
'<strong class="tux-search-highlight">', $text );
206 $text = str_replace( $post,
'</strong>', $text );
208 $title = Title::newFromText( $document[
'localid'] .
'/' . $document[
'language'] );
215 'class' =>
'row tux-message',
216 'data-title' => $title->getPrefixedText(),
217 'data-language' => $document[
'language'],
220 $handle =
new MessageHandle( $title );
222 if ( $handle->isValid() ) {
223 $uri = Utilities::getEditorUrl( $handle );
224 $link = Html::element(
227 $this->msg(
'tux-sst-edit' )->text()
230 $url = $this->urlUtils->parse( $document[
'uri'] );
234 $domain = $url[
'host'];
235 $link = Html::element(
237 [
'href' => $document[
'uri'] ],
238 $this->msg(
'tux-sst-view-foreign', $domain )->text()
242 $access = Html::rawElement(
244 [
'class' =>
'row tux-edit tux-message-item' ],
248 $titleText = $title->getPrefixedText();
250 'class' =>
'row tux-title',
254 $language = $this->languageFactory->getLanguage( $document[
'language'] );
256 'class' =>
'row tux-text',
257 'lang' => $language->getHtmlCode(),
258 'dir' => $language->getDir(),
261 $resultsHtml .= Html::openElement(
'div', $resultAttribs )
262 . Html::rawElement(
'div', $textAttribs, $text )
263 . Html::element(
'div', $titleAttribs, $titleText )
265 . Html::closeElement(
'div' );
268 $resultsHtml .= Html::rawElement(
'hr', [
'class' =>
'tux-pagination-line' ] );
271 $offset = $this->opts->getValue(
'offset' );
272 $params = $this->opts->getChangedValues();
274 if ( $total - $offset > $this->limit ) {
275 $newParams = [
'offset' => $offset +
$this->limit ] + $params;
277 'class' =>
'mw-ui-button pager-next',
278 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
280 $next = Html::element(
'a', $attribs, $this->msg(
'tux-sst-next' )->text() );
283 $newParams = [
'offset' => max( 0, $offset - $this->limit ) ] + $params;
285 'class' =>
'mw-ui-button pager-prev',
286 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
288 $prev = Html::element(
'a', $attribs, $this->msg(
'tux-sst-prev' )->text() );
291 $resultsHtml .= Html::rawElement(
'div', [
'class' =>
'tux-pagination-links' ],
295 $count = $this->msg(
'tux-sst-count' )->numParams( $total )->escaped();
297 $this->showSearch( $search, $count, $facetHtml, $resultsHtml, $total );
300 private function getLanguages( array $facet ): array {
303 $nonDefaults = $this->opts->getChangedValues();
304 $selected = $this->opts->getValue(
'language' );
305 $filter = $this->opts->getValue(
'filter' );
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'] );
317 $nonDefaults[
'language'] = $key;
320 $url = $this->getPageTitle()->getLocalURL( $nonDefaults );
321 $value = $this->getLanguage()->formatNum( $value );
332 private function getGroups( array $facet ): array {
333 $structure = MessageGroups::getGroupStructure();
334 return $this->makeGroupFacetRows( $structure, $facet );
337 private function makeGroupFacetRows(
341 string $pathString =
''
345 $nonDefaults = $this->opts->getChangedValues();
346 $selected = $this->opts->getValue(
'group' );
347 $path = explode(
'|', $this->opts->getValue(
'grouppath' ) );
349 foreach ( $groups as $mixed ) {
350 $subgroups = $group = $mixed;
352 if ( is_array( $mixed ) ) {
353 $group = array_shift( $subgroups );
357 '@phan-var \MessageGroup $group';
358 $id = $group->getId();
360 if ( $id !== $selected && !isset( $counts[$id] ) ) {
364 if ( $id === $selected ) {
365 unset( $nonDefaults[
'group'] );
366 $nonDefaults[
'grouppath'] = $pathString;
368 $nonDefaults[
'group'] = $id;
369 $nonDefaults[
'grouppath'] = $pathString . $id;
372 $value = $counts[$id] ?? 0;
377 'label' => $group->getLabel(),
380 if ( isset( $path[$level] ) && $path[$level] === $id ) {
381 $output[$id][
'groups'] = $this->makeGroupFacetRows(
393 private function showSearch(
400 $messageSelector = $this->messageSelector();
401 $this->getOutput()->addHTML(
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>
416 $query = trim( $this->opts->getValue(
'query' ) );
417 $hasSpace = preg_match(
'/\s/', $query );
418 $match = $this->opts->getValue(
'match' );
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>";
427 $out = $this->getOutput();
430 $out->msg(
'tux-sst-match-message', $link )->parse()
435 $this->getOutput()->addHTML(
437 <div
class=
"row searchcontent">
438 <div
class=
"three columns facets">$facets</div>
439 <div
class=
"nine columns results">$results</div>
446 private function showEmptySearch(): void {
447 $search = $this->getSearchInput(
'' );
448 $this->getOutput()->addHTML(
450 <div
class=
"grid tux-searchpage">
451 <div
class=
"row searchinput">
452 <div
class=
"nine columns offset-by-three">$search</div>
459 private function showSearchError(
string $search, Message $message ): void {
460 $messageSelector = $this->messageSelector();
461 $messageHTML = Html::errorBox(
466 $this->getOutput()->addHTML(
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>
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',
494 $this->msg(
'tux-sst-ellipsis-' . $key )->text()
497 return Html::rawElement(
'li', [
499 'data-filter' => $value,
500 'data-title' => $key,
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' ] );
512 'translated' =>
'translated',
513 'untranslated' =>
'untranslated'
517 'outdated' =>
'fuzzy'
520 $selected = $this->opts->getValue(
'filter' );
521 if ( in_array( $selected, $ellipsisOptions ) ) {
522 $ellipsisOptions = array_slice( $tabs, -1 );
526 $tabs = array_merge( $tabs, [
'outdated' => $selected ] );
527 } elseif ( !in_array( $selected, $tabs ) ) {
531 $container = Html::openElement(
'ul', [
'class' =>
'column tux-message-selector' ] );
532 foreach ( $ellipsisOptions as $optKey => $optValue ) {
533 $container .= $this->ellipsisSelector( $optKey, $optValue );
536 $sourceLanguage = $this->opts->getValue(
'sourcelanguage' );
537 $sourceLanguage = Utilities::getLanguageName( $sourceLanguage );
538 foreach ( $tabs as $tab => $filter ) {
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(
552 $this->msg( $tabClass )->text()
555 $link = Html::element(
558 $this->msg( $tabClass, $sourceLanguage )->text()
562 if ( $selected === $filter ) {
563 $tabClass .=
' selected';
565 $output .= Html::rawElement(
'li', [
566 'class' => [
'column', $tabClass ],
567 'data-filter' => $filter,
568 'data-title' => $tab,
573 $output .= Html::rawElement(
'li', [
'class' =>
'column more' ],
'...' . $container );
574 $output .= Html::closeElement(
'ul' ) . Html::closeElement(
'div' ) . Html::closeElement(
'div' );
579 private function getSearchInput(
string $query ): string {
581 'placeholder' => $this->msg(
'tux-sst-search-ph' )->text(),
582 'class' =>
'searchinputbox mw-ui-input',
583 'dir' => $this->getLanguage()->getDir()
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' ]
593 $typeHint = Html::rawElement(
595 [
'class' =>
'tux-searchinputbox-hint' ],
596 $this->msg(
'tux-sst-search-info' )->parse()
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(),
608 $checkLabel = Html::rawElement(
610 [
'class' =>
'tux-search-operators mw-ui-checkbox' ],
614 $lang = $this->getRequest()->getVal(
'language' );
615 $language = $lang ===
null ?
'' : Html::hidden(
'language', $lang );
617 return Html::rawElement(
619 [
'action' => wfScript(),
'name' =>
'searchform' ],
620 $title . $input . $submit . $typeHint . $checkLabel . $language
624 protected function getGroupName() {
625 return 'translation';