Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 412
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchTranslationsSpecialPage
0.00% covered (danger)
0.00%
0 / 412
0.00% covered (danger)
0.00%
0 / 12
3306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 194
0.00% covered (danger)
0.00%
0 / 1
600
 getLanguages
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 getGroups
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeGroupFacetRows
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 showSearch
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 showEmptySearch
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 showSearchError
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 ellipsisSelector
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 messageSelector
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
56
 getSearchInput
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\TtmServer;
5
6use ErrorPageError;
7use MediaWiki\Extension\Translate\LogNames;
8use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
9use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
10use MediaWiki\Extension\Translate\TranslatorInterface\Aid\CurrentTranslationAid;
11use MediaWiki\Extension\Translate\TranslatorInterface\Aid\TranslationAidDataProvider;
12use MediaWiki\Extension\Translate\Utilities\Utilities;
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
27/**
28 * Contains logic to search for translations
29 *
30 * @author Niklas Laxström
31 * @license GPL-2.0-or-later
32 * @ingroup SpecialPage TranslateSpecialPage
33 */
34class SearchTranslationsSpecialPage extends SpecialPage {
35    private FormOptions $opts;
36    /**
37     * Placeholders used for highlighting. Search backend can mark the beginning and
38     * end but, we need to run htmlspecialchars on the result first and then
39     * replace the placeholders with the html. It is assumed placeholders
40     * don't contain any chars that are escaped in html.
41     */
42    private array $hl;
43    /** How many search results to display per page */
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
508    /** Build ellipsis to select options */
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
529    /** Design the tabs */
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}