35 private FormOptions $opts;
46 private LanguageFactory $languageFactory;
47 private UrlUtils $urlUtils;
48 private LoggerInterface $logger;
50 public function __construct(
52 LanguageFactory $languageFactory,
55 parent::__construct(
'SearchTranslations' );
57 Utilities::getPlaceholder(),
58 Utilities::getPlaceholder(),
61 $this->ttmServerFactory = $ttmServerFactory;
62 $this->languageFactory = $languageFactory;
63 $this->urlUtils = $urlUtils;
67 public function execute( $subPage ) {
69 $this->checkPermissions();
71 $server = $this->ttmServerFactory->getDefaultForQuerying();
73 throw new ErrorPageError(
'tux-sst-nosolr-title',
'tux-sst-nosolr-body' );
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 )
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 );
101 $opts->fetchValuesFromRequest( $this->getRequest() );
103 $queryString = $opts->getValue(
'query' );
105 if ( $queryString ===
'' ) {
106 $this->showEmptySearch();
110 $search = $this->getSearchInput( $queryString );
112 $crossLanguageSearch =
false;
113 $options = $params = $opts->getAllValues();
114 $filter = $opts->getValue(
'filter' );
116 if ( $opts->getValue(
'language' ) ===
'' ) {
117 $options[
'language'] = $this->getLanguage()->getCode();
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' ) );
126 $opts->setValue(
'language', $options[
'language'] );
127 $documents = $translationSearch->getDocuments();
128 $total = $translationSearch->getTotalHits();
129 $resultSet = $translationSearch->getResultSet();
131 $crossLanguageSearch =
true;
133 $resultSet = $server->search( $queryString, $params, $this->hl );
134 $documents = $server->getDocuments( $resultSet );
135 $total = $server->getTotalHits( $resultSet );
137 }
catch ( TtmServerException $e ) {
138 $message = $e->getMessage();
140 if ( preg_match(
'/^Result window is too large/', $message ) ) {
141 $this->showSearchError( $search, $this->msg(
'tux-sst-error-offset' ) );
146 $this->logger->error(
147 'Translation search server unavailable: {exception}',
148 [
'exception' => $e ]
150 throw new ErrorPageError(
'tux-sst-solr-offline-title',
'tux-sst-solr-offline-body' );
154 $facets = $server->getFacets( $resultSet );
157 if ( $facets[
'language'] !== [] ) {
158 if ( $filter !==
'' ) {
159 $facets[
'language'] = array_merge(
161 [ $opts->getValue(
'language' ) => $total ]
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' ),
169 $this->msg(
'tux-sst-facet-language' )->text()
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()
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(
199 'wiki' => WikiMap::getCurrentWikiId(),
200 'localid' => $handle->getTitleForBase()->getPrefixedText(),
201 'content' => $aid->getData()[
'value'],
202 'language' => $handle->getCode(),
204 array_unshift( $documents, $document );
209 foreach ( $documents as $document ) {
210 $text = $document[
'content'];
211 if ( $text ===
null ) {
214 $text = Utilities::convertWhiteSpaceToHTML( $text );
216 [ $pre, $post ] = $this->hl;
217 $text = str_replace( $pre,
'<strong class="tux-search-highlight">', $text );
218 $text = str_replace( $post,
'</strong>', $text );
220 $titleText = $document[
'localid'] .
'/' . $document[
'language'];
221 $title = Title::newFromText( $titleText );
224 $this->logger->warning(
225 'SearchTranslationsSpecialPage: Invalid title: {title}',
226 [
'title' => $titleText,
'document' => json_encode( $document ) ]
232 'class' =>
'row tux-message',
233 'data-title' => $title->getPrefixedText(),
234 'data-language' => $document[
'language'],
237 $handle =
new MessageHandle( $title );
239 if ( $handle->isValid() ) {
240 $uri = Utilities::getEditorUrl( $handle,
'search' );
241 $link = Html::element(
244 $this->msg(
'tux-sst-edit' )->text()
247 if ( $crossLanguageSearch ) {
248 $this->logger->warning(
249 'SearchTranslationsSpecialPage: Expected valid handle: {title}',
250 [
'title' => $title->getPrefixedText() ]
255 $url = $this->urlUtils->parse( $document[
'uri'] );
259 $domain = $url[
'host'];
260 $link = Html::element(
262 [
'href' => $document[
'uri'] ],
263 $this->msg(
'tux-sst-view-foreign', $domain )->text()
267 $access = Html::rawElement(
269 [
'class' =>
'row tux-edit tux-message-item' ],
273 $titleText = $title->getPrefixedText();
275 'class' =>
'row tux-title',
279 $language = $this->languageFactory->getLanguage( $document[
'language'] );
281 'class' =>
'row tux-text',
282 'lang' => $language->getHtmlCode(),
283 'dir' => $language->getDir(),
286 $resultsHtml .= Html::openElement(
'div', $resultAttribs )
287 . Html::rawElement(
'div', $textAttribs, $text )
288 . Html::element(
'div', $titleAttribs, $titleText )
290 . Html::closeElement(
'div' );
293 $resultsHtml .= Html::rawElement(
'hr', [
'class' =>
'tux-pagination-line' ] );
296 $offset = $this->opts->getValue(
'offset' );
297 $params = $this->opts->getChangedValues();
299 if ( $total - $offset > $this->limit ) {
300 $newParams = [
'offset' => $offset +
$this->limit ] + $params;
302 'class' =>
'mw-ui-button pager-next',
303 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
305 $next = Html::element(
'a', $attribs, $this->msg(
'tux-sst-next' )->text() );
308 $newParams = [
'offset' => max( 0, $offset - $this->limit ) ] + $params;
310 'class' =>
'mw-ui-button pager-prev',
311 'href' => $this->getPageTitle()->getLocalURL( $newParams ),
313 $prev = Html::element(
'a', $attribs, $this->msg(
'tux-sst-prev' )->text() );
316 $resultsHtml .= Html::rawElement(
'div', [
'class' =>
'tux-pagination-links' ],
320 $count = $this->msg(
'tux-sst-count' )->numParams( $total )->escaped();
322 $this->showSearch( $search, $count, $facetHtml, $resultsHtml, $total );
325 private function getLanguages( array $facet ): array {
328 $nonDefaults = $this->opts->getChangedValues();
329 $selected = $this->opts->getValue(
'language' );
330 $filter = $this->opts->getValue(
'filter' );
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'] );
342 $nonDefaults[
'language'] = $key;
345 $url = $this->getPageTitle()->getLocalURL( $nonDefaults );
346 $value = $this->getLanguage()->formatNum( $value );
357 private function getGroups( array $facet ): array {
358 $structure = MessageGroups::getGroupStructure();
359 return $this->makeGroupFacetRows( $structure, $facet );
362 private function makeGroupFacetRows(
366 string $pathString =
''
370 $nonDefaults = $this->opts->getChangedValues();
371 $selected = $this->opts->getValue(
'group' );
372 $path = explode(
'|', $this->opts->getValue(
'grouppath' ) );
374 foreach ( $groups as $mixed ) {
375 $subgroups = $group = $mixed;
377 if ( is_array( $mixed ) ) {
378 $group = array_shift( $subgroups );
382 '@phan-var \MessageGroup $group';
383 $id = $group->getId();
385 if ( $id !== $selected && !isset( $counts[$id] ) ) {
389 if ( $id === $selected ) {
390 unset( $nonDefaults[
'group'] );
391 $nonDefaults[
'grouppath'] = $pathString;
393 $nonDefaults[
'group'] = $id;
394 $nonDefaults[
'grouppath'] = $pathString . $id;
397 $value = $counts[$id] ?? 0;
402 'label' => $group->getLabel(),
405 if ( isset( $path[$level] ) && $path[$level] === $id ) {
406 $output[$id][
'groups'] = $this->makeGroupFacetRows(
418 private function showSearch(
425 $messageSelector = $this->messageSelector();
426 $this->getOutput()->addHTML(
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>
441 $query = trim( $this->opts->getValue(
'query' ) );
442 $hasSpace = preg_match(
'/\s/', $query );
443 $match = $this->opts->getValue(
'match' );
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>";
452 $out = $this->getOutput();
455 $out->msg(
'tux-sst-match-message', $link )->parse()
460 $this->getOutput()->addHTML(
462 <div
class=
"row searchcontent">
463 <div
class=
"three columns facets">$facets</div>
464 <div
class=
"nine columns results">$results</div>
471 private function showEmptySearch(): void {
472 $search = $this->getSearchInput(
'' );
473 $this->getOutput()->addHTML(
475 <div
class=
"grid tux-searchpage">
476 <div
class=
"row searchinput">
477 <div
class=
"nine columns offset-by-three">$search</div>
484 private function showSearchError(
string $search, Message $message ): void {
485 $messageSelector = $this->messageSelector();
486 $messageHTML = Html::errorBox(
491 $this->getOutput()->addHTML(
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>
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',
519 $this->msg(
'tux-sst-ellipsis-' . $key )->text()
522 return Html::rawElement(
'li', [
524 'data-filter' => $value,
525 'data-title' => $key,
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' ] );
537 'translated' =>
'translated',
538 'untranslated' =>
'untranslated'
542 'outdated' =>
'fuzzy'
545 $selected = $this->opts->getValue(
'filter' );
546 if ( in_array( $selected, $ellipsisOptions ) ) {
547 $ellipsisOptions = array_slice( $tabs, -1 );
551 $tabs = array_merge( $tabs, [
'outdated' => $selected ] );
552 } elseif ( !in_array( $selected, $tabs ) ) {
556 $container = Html::openElement(
'ul', [
'class' =>
'column tux-message-selector' ] );
557 foreach ( $ellipsisOptions as $optKey => $optValue ) {
558 $container .= $this->ellipsisSelector( $optKey, $optValue );
561 $sourceLanguage = $this->opts->getValue(
'sourcelanguage' );
562 $sourceLanguage = Utilities::getLanguageName( $sourceLanguage );
563 foreach ( $tabs as $tab => $filter ) {
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(
577 $this->msg( $tabClass )->text()
580 $link = Html::element(
583 $this->msg( $tabClass, $sourceLanguage )->text()
587 if ( $selected === $filter ) {
588 $tabClass .=
' selected';
590 $output .= Html::rawElement(
'li', [
591 'class' => [
'column', $tabClass ],
592 'data-filter' => $filter,
593 'data-title' => $tab,
598 $output .= Html::rawElement(
'li', [
'class' =>
'column more' ],
'...' . $container );
599 $output .= Html::closeElement(
'ul' ) . Html::closeElement(
'div' ) . Html::closeElement(
'div' );
604 private function getSearchInput(
string $query ): string {
606 'placeholder' => $this->msg(
'tux-sst-search-ph' )->text(),
607 'class' =>
'searchinputbox mw-ui-input',
608 'dir' => $this->getLanguage()->getDir()
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' ]
618 $typeHint = Html::rawElement(
620 [
'class' =>
'tux-searchinputbox-hint' ],
621 $this->msg(
'tux-sst-search-info' )->parse()
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(),
633 $checkLabel = Html::rawElement(
635 [
'class' =>
'tux-search-operators mw-ui-checkbox' ],
639 $lang = $this->getRequest()->getVal(
'language' );
640 $language = $lang ===
null ?
'' : Html::hidden(
'language', $lang );
642 return Html::rawElement(
644 [
'action' => wfScript(),
'name' =>
'searchform' ],
645 $title . $input . $submit . $typeHint . $checkLabel . $language
649 protected function getGroupName() {
650 return 'translation';