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