Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 490
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMediaSearch
0.00% covered (danger)
0.00%
0 / 490
0.00% covered (danger)
0.00%
0 / 20
11772
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 1
210
 findExactMatchRedirectUrl
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 redirectOnExactMatch
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getType
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 search
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
272
 getActiveFilters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 assertValidFilters
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getFiltersForDisplay
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 getTermWithFilters
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getAssessments
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getSearchKeywords
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getSort
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getThumbLimits
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getSearchNamespaces
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 extractSuggestedTerm
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 generateDidYouMeanLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getResultData
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
650
1<?php
2
3namespace MediaWiki\Extension\MediaSearch\Special;
4
5use ApiBase;
6use ApiMain;
7use CirrusSearch\Parser\FullTextKeywordRegistry;
8use CirrusSearch\SearchConfig;
9use DerivativeContext;
10use MediaWiki\Config\Config;
11use MediaWiki\Config\ConfigException;
12use MediaWiki\Extension\MediaSearch\InvalidFiltersException;
13use MediaWiki\Extension\MediaSearch\InvalidNamespaceGroupException;
14use MediaWiki\Extension\MediaSearch\NoCirrusSearchException;
15use MediaWiki\Extension\MediaSearch\SearchFailedException;
16use MediaWiki\Extension\MediaSearch\SearchOptions;
17use MediaWiki\Html\TemplateParser;
18use MediaWiki\Linker\LinkRenderer;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Output\OutputPage;
21use MediaWiki\Request\FauxRequest;
22use MediaWiki\SiteStats\SiteStats;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Title\NamespaceInfo;
25use MediaWiki\Title\Title;
26use MediaWiki\User\Options\UserOptionsManager;
27use OOUI\Tag;
28use RequestContext;
29use SearchEngine;
30use SearchEngineFactory;
31use Wikimedia\Assert\Assert;
32
33/**
34 * Special page specifically for searching multimedia pages.
35 */
36class SpecialMediaSearch extends SpecialPage {
37    /**
38     * @var NamespaceInfo
39     */
40    protected $namespaceInfo;
41
42    /**
43     * @var ApiBase
44     */
45    protected $api;
46
47    /**
48     * @var TemplateParser
49     */
50    protected $templateParser;
51
52    /**
53     * @var SearchConfig
54     */
55    protected $searchConfig;
56
57    /**
58     * @var Config
59     */
60    protected $mainConfig;
61
62    /**
63     * @var SearchOptions
64     */
65    private $searchOptions;
66
67    /**
68     * @var UserOptionsManager
69     */
70    private $userOptionsManager;
71
72    /**
73     * @var SearchEngine
74     */
75    private $searchEngine;
76
77    /**
78     * @var LinkRenderer
79     */
80    private $linkRenderer;
81
82    /**
83     * @inheritDoc
84     */
85    public function __construct(
86        SearchEngineFactory $searchEngineFactory,
87        NamespaceInfo $namespaceInfo,
88        UserOptionsManager $userOptionsManager,
89        LinkRenderer $linkRenderer,
90        $name = 'MediaSearch',
91        ApiBase $api = null,
92        TemplateParser $templateParser = null,
93        SearchConfig $searchConfig = null,
94        Config $mainConfig = null
95    ) {
96        parent::__construct( $name );
97
98        $this->namespaceInfo = $namespaceInfo;
99        $this->api = $api ?: new ApiMain( new FauxRequest() );
100        $this->templateParser = $templateParser ?: new TemplateParser(
101            __DIR__ . '/../../templates'
102        );
103        try {
104            $this->searchConfig = $searchConfig ?? MediaWikiServices::getInstance()
105                ->getConfigFactory()
106                ->makeConfig( 'CirrusSearch' );
107        } catch ( ConfigException $e ) {
108            // CirrusSearch not installed
109        }
110
111        $this->mainConfig = $mainConfig ?? MediaWikiServices::getInstance()
112            ->getConfigFactory()
113            ->makeConfig( 'main' );
114
115        $this->userOptionsManager = $userOptionsManager;
116
117        $this->searchEngine = $searchEngineFactory->create();
118
119        $this->searchOptions = SearchOptions::getInstanceFromContext( $this->getContext() );
120
121        $this->linkRenderer = $linkRenderer;
122    }
123
124    /**
125     * @inheritDoc
126     */
127    public function getDescription() {
128        return $this->msg( 'mediasearch-title' );
129    }
130
131    /**
132     * @return string
133     */
134    protected function getGroupName() {
135        return 'pages';
136    }
137
138    /**
139     * @inheritDoc
140     */
141    public function execute( $subPage ) {
142        OutputPage::setupOOUI();
143        $userLanguage = $this->getLanguage();
144
145        // url & querystring params of this page
146        $url = $this->getRequest()->getRequestURL();
147
148        // Discard query param keys or values that are not strings to sanitize before using
149        $queryParams = array_filter( $this->getRequest()->getValues(), static function ( $v, $k ) {
150            return is_string( $k ) && is_string( $v );
151        }, ARRAY_FILTER_USE_BOTH );
152
153        $term = str_replace( "\n", ' ', $this->getRequest()->getText( 'search' ) );
154        $redirectUrl = $this->findExactMatchRedirectUrl( $term );
155        if ( $redirectUrl !== null ) {
156            $this->getOutput()->redirect( $redirectUrl );
157            return;
158        }
159        $tabs = [];
160
161        $tabOrder = [
162            SearchOptions::TYPE_IMAGE,
163            SearchOptions::TYPE_AUDIO,
164            SearchOptions::TYPE_VIDEO,
165            SearchOptions::TYPE_OTHER,
166            SearchOptions::TYPE_PAGE
167        ];
168        if ( $this->mainConfig->get( 'MediaSearchTabOrder' ) ) {
169            $tabOrder = array_intersect(
170                $this->mainConfig->get( 'MediaSearchTabOrder' ),
171                $tabOrder
172            );
173        }
174
175        $type = $this->getType( $term, $queryParams, $tabOrder );
176
177        $tabDefinitions = [
178            'image' => [
179                'type' => SearchOptions::TYPE_IMAGE,
180                'label' => $this->msg( 'mediasearch-tab-image' )->text(),
181                'isActive' => $type === SearchOptions::TYPE_IMAGE,
182                'isImage' => true,
183            ],
184            'audio' => [
185                'type' => SearchOptions::TYPE_AUDIO,
186                'label' => $this->msg( 'mediasearch-tab-audio' )->text(),
187                'isActive' => $type === SearchOptions::TYPE_AUDIO,
188                'isAudio' => true,
189            ],
190            'video' => [
191                'type' => SearchOptions::TYPE_VIDEO,
192                'label' => $this->msg( 'mediasearch-tab-video' )->text(),
193                'isActive' => $type === SearchOptions::TYPE_VIDEO,
194                'isVideo' => true,
195            ],
196            'other' => [
197                'type' => SearchOptions::TYPE_OTHER,
198                'label' => $this->msg( 'mediasearch-tab-other' )->text(),
199                'isActive' => $type === SearchOptions::TYPE_OTHER,
200                'isOther' => true,
201            ],
202            'page' => [
203                'type' => SearchOptions::TYPE_PAGE,
204                'label' => $this->msg( 'mediasearch-tab-page' )->text(),
205                'isActive' => $type === SearchOptions::TYPE_PAGE,
206                'isPage' => true,
207            ],
208        ];
209
210        foreach ( $tabOrder as $tabPlace ) {
211            array_push( $tabs, $tabDefinitions[ $tabPlace ] );
212        }
213
214        $limit = $this->getRequest()->getText( 'limit' ) ? (int)$this->getRequest()->getText( 'limit' ) : 40;
215        $error = [];
216        $results = [];
217        $searchinfo = [];
218        $continue = null;
219        $filtersForDisplay = [];
220        $activeFilters = $this->getActiveFilters( $queryParams );
221
222        try {
223            $this->assertValidFilters( $activeFilters, $type );
224            $filtersForDisplay = $this->getFiltersForDisplay( $activeFilters, $type );
225            $termWithFilters = $this->getTermWithFilters( $term, $activeFilters );
226
227            // Actually perform the search. This method will throw an error if the
228            // user enters a bad query (illegal characters, etc)
229            [ $results, $searchinfo, $continue ] = $this->search(
230                $termWithFilters,
231                $type,
232                $this->getSearchNamespaces( $activeFilters, $type ),
233                $limit,
234                $this->getRequest()->getText( 'continue' ),
235                $this->getSort( $activeFilters )
236            );
237        } catch (
238            InvalidNamespaceGroupException | InvalidFiltersException |
239            NoCirrusSearchException | SearchFailedException $_
240        ) {
241            $error = [
242                'title' => $this->msg( 'mediasearch-error-message' )->text(),
243                'text' => $this->msg( 'mediasearch-error-text' )->text(),
244            ];
245        }
246
247        $totalSiteImages = $userLanguage->formatNum( SiteStats::images() );
248        $thumbLimits = $this->getThumbLimits();
249
250        // Handle optional searchinfo that may be present in the API response:
251        $totalHits = $searchinfo['totalhits'] ?? 0;
252        $didYouMean = null;
253        $didYouMeanLink = null;
254        $currentResultStart = $this->getRequest()->getText( 'continue' ) ?: 0;
255
256        if ( isset( $searchinfo[ 'suggestion' ] ) ) {
257            try {
258                $didYouMean = $this->extractSuggestedTerm( $searchinfo['suggestion'], $activeFilters );
259                $didYouMeanLink = $this->generateDidYouMeanLink( $queryParams, $didYouMean );
260            } catch ( NoCirrusSearchException $_ ) {
261                // Ignore.
262            }
263        }
264
265        $mappedQueryParams = array_map( static function ( $key, $value ) {
266            return [
267                'key' => $key,
268                'value' => $value,
269                'is' . ucfirst( $key ) => true,
270            ];
271        }, array_keys( $queryParams ), array_values( $queryParams ) );
272
273        $data = [
274            'queryParams' => $mappedQueryParams,
275            'page' => $url,
276            'path' => parse_url( $url, PHP_URL_PATH ),
277            'term' => $term,
278            'hasTerm' => (bool)$term,
279            'limit' => $limit,
280            'activeType' => $type,
281            'tabs' => $tabs,
282            'error' => $error,
283            'results' => array_map(
284                function ( $result ) use ( $results, $type ) {
285                    return $this->getResultData( $result, $results, $type );
286                },
287                $results
288            ),
289            'continue' => $continue,
290            'hasFilters' => count( $activeFilters ) > 0,
291            'activeFilters' => array_values( $activeFilters ),
292            'filtersForDisplay' => array_values( $filtersForDisplay ),
293            'clearFiltersUrl' => $this->getPageTitle()->getLinkURL( array_diff( $queryParams, $activeFilters ) ),
294            'clearFiltersText' => $this->msg( 'mediasearch-clear-filters' )->text(),
295            'hasLess' => $currentResultStart > 0,
296            'previousStart' => max( $currentResultStart - $limit, 0 ),
297            'hasMore' => $continue !== null,
298            'endOfResults' => count( $results ) > 0 && $continue === null,
299            'endOfResultsMessage' => $this->msg( 'mediasearch-end-of-results' )->text(),
300            'errorTitle' => $this->msg( 'mediasearch-error-message' )->text(),
301            'errorText' => $this->msg( 'mediasearch-error-text' )->text(),
302            'searchLabel' => $this->msg( 'mediasearch-input-label' )->text(),
303            'searchButton' => $this->msg( 'searchbutton' )->text(),
304            'searchPlaceholder' => $this->msg( 'mediasearch-input-placeholder' )->text(),
305            'continueMessage' => $this->msg( 'mediasearch-load-more-results' )->text(),
306            'previousMessage' => $this->msg( 'mediasearch-load-less-results' )->text(),
307            'emptyMessage' => $this->msg( 'mediasearch-empty-state', $totalSiteImages )
308                ->text(),
309            'noResultsMessage' => $this->msg( 'mediasearch-no-results' )->text(),
310            'noResultsMessageExtra' => $this->msg( 'mediasearch-no-results-tips' )->text(),
311            'didYouMean' => $didYouMean,
312            // phpcs:ignore Generic.Files.LineLength.TooLong
313            'didYouMeanMessage' => $didYouMean ? $this->msg( 'mediasearch-did-you-mean' )->rawParams( $didYouMeanLink )->parse() : null,
314            'totalHits' => $totalHits,
315            'showResultsCount' => $totalHits > 0,
316            'resultsCount' => $this->msg(
317                'mediasearch-results-count',
318                $userLanguage->formatNum( $totalHits )
319            )->text(),
320            'autofocus' => !$term
321        ];
322
323        $externalEntitySearchBaseUri = $this->getConfig()->get( 'MediaSearchExternalEntitySearchBaseUri' );
324        if ( $externalEntitySearchBaseUri === '' ) {
325            // fall back to local uri if blank
326            // (but not in other `false` cases, which deactivate autocomplete)
327            $externalEntitySearchBaseUri = wfScript( 'api' );
328        }
329
330        $this->getOutput()->addHTML( $this->templateParser->processTemplate( 'SERPWidget', $data ) );
331        $this->getOutput()->addModuleStyles( [ 'codex-styles', 'mediasearch.styles' ] );
332        $this->getOutput()->addModules( [ 'mediasearch' ] );
333        $this->getOutput()->addJsConfigVars( [
334            'sdmsInitialSearchResults' => $data,
335            'sdmsTotalSiteImages' => $totalSiteImages,
336            'sdmsExternalEntitySearchBaseUri' => $externalEntitySearchBaseUri,
337            'sdmsExternalSearchUri' => $this->getConfig()->get( 'MediaSearchExternalSearchUri' ) ?: wfScript( 'api' ),
338            'sdmsThumbLimits' => $thumbLimits,
339            'sdmsThumbRenderMap' => $this->getConfig()->get( 'UploadThumbnailRenderMap' ),
340            'sdmsInitialFilters' => json_encode( (object)$activeFilters ),
341            'sdmsDidYouMean' => $didYouMean,
342            'sdmsHasError' => (bool)$error,
343            'sdmsNamespaceGroups' => $this->searchOptions->getNamespaceGroups(),
344            'sdmsAssessmentQuickviewLabels' => $this->getConfig()->get( 'MediaSearchAssessmentQuickviewLabels' )
345        ] );
346
347        $specialSearchUrl = SpecialPage::getTitleFor( 'Search' )->getLocalURL( [ 'search' => $term ] );
348        $helpUrl = 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:MediaSearch';
349        $this->getOutput()->setIndicators( [
350            $this->getLanguage()->pipeList( [
351                ( new Tag( 'a' ) )
352                    ->setAttributes( [ 'href' => $specialSearchUrl ] )
353                    // phpcs:ignore Generic.Files.LineLength.TooLong
354                    ->appendContent( $this->msg( 'mediasearch-switch-special-search' )->escaped() ),
355                ( new Tag( 'a' ) )
356                    ->addClasses( [ 'mw-helplink' ] )
357                    ->setAttributes( [ 'href' => $helpUrl, 'target' => '_blank' ] )
358                    ->appendContent( $this->msg( 'helppage-top-gethelp' )->escaped() ),
359            ] )
360        ] );
361
362        return parent::execute( $subPage );
363    }
364
365    /**
366     * Find an exact title match if there is one, and if we ought to redirect to it then
367     * return its url
368     *
369     * @see SpecialSearch.php
370     * @param string $term
371     * @return string|null The url to redirect to, or null if no redirect.
372     */
373    private function findExactMatchRedirectUrl( $term ) {
374        $request = $this->getRequest();
375        if ( $request->getCheck( 'type' ) ) {
376            // If type is set, then the user is searching directly on Special:MediaSearch,
377            // so do not redirect (the redirect should only happen when the user searches
378            // from the site-wide searchbox)
379            return null;
380        }
381        // If the term cannot be used to create a title then there is no match
382        if ( Title::newFromText( $term ) === null ) {
383            return null;
384        }
385        // Find an exact (or very near) match
386        $title = $this->searchEngine
387            ->getNearMatcher( $this->getConfig() )->getNearMatch( $term );
388        if ( $title === null ) {
389            return null;
390        }
391        $url = null;
392        if ( !$this->getHookRunner()->onSpecialSearchGoResult( $term, $title, $url ) ) {
393            return null;
394        }
395
396        if (
397            // If there is a preference set to NOT redirect on exact page match
398            // then return null (which prevents direction)
399            !$this->redirectOnExactMatch()
400            // BUT ...
401            // ... ignore no-redirect preference if the exact page match is an interwiki link
402            && !$title->isExternal()
403            // ... ignore no-redirect preference if the exact page match is NOT in the main
404            // namespace AND there's a namespace in the search string
405            && !( $title->getNamespace() !== NS_MAIN && strpos( $term, ':' ) > 0 )
406        ) {
407            return null;
408        }
409
410        return $url ?? $title->getFullUrlForRedirect();
411    }
412
413    private function redirectOnExactMatch() {
414        if ( !$this->getConfig()->get( 'SearchMatchRedirectPreference' ) ) {
415            // If the preference for whether to redirect is disabled, use the default setting
416            return $this->userOptionsManager->getDefaultOption(
417                'search-match-redirect',
418                $this->getUser()
419            );
420        } else {
421            // Otherwise use the user's preference
422            return $this->userOptionsManager->getOption( $this->getUser(), 'search-match-redirect' );
423        }
424    }
425
426    /**
427     * Get media type.
428     *
429     * @param string $term
430     * @param array $queryParams
431     * @param array $tabOrderConfig
432     * @return string
433     */
434    private function getType( string $term, array $queryParams, array $tabOrderConfig ): string {
435        $title = Title::newFromText( $term );
436        if ( $title !== null && !in_array( $title->getNamespace(), [ NS_FILE, NS_MAIN ] ) ) {
437            return SearchOptions::TYPE_PAGE;
438        }
439
440        if ( isset( $queryParams['type'] ) && in_array( $queryParams['type'], SearchOptions::ALL_TYPES ) ) {
441            // If type is specified AND matches one of the supported types, use it
442            return $queryParams['type'];
443        } else {
444            // Otherwise, default to the first prescribed tab
445            return $tabOrderConfig[0];
446        }
447    }
448
449    /**
450     * @param string $term
451     * @param string $type
452     * @param int[] $namespaces
453     * @param int|null $limit
454     * @param string|null $continue
455     * @param string|null $sort
456     * @return array [ search results, searchinfo data, continuation value ]
457     * @throws SearchFailedException
458     */
459    protected function search(
460        $term,
461        $type,
462        $namespaces,
463        $limit = null,
464        $continue = null,
465        $sort = 'relevance'
466    ): array {
467        Assert::parameterType( 'string', $term, '$term' );
468        Assert::parameterType( 'string', $type, '$type' );
469        Assert::parameterType( 'integer|null', $limit, '$limit' );
470        Assert::parameterType( 'string|null', $continue, '$continue' );
471        Assert::parameterType( 'string|null', $sort, '$sort' );
472
473        if ( $term === '' ) {
474            return [ [], [], null ];
475        }
476
477        $langCode = $this->getLanguage()->getCode();
478
479        if ( $type === SearchOptions::TYPE_PAGE ) {
480            $request = new FauxRequest( [
481                'format' => 'json',
482                'uselang' => $langCode,
483                'action' => 'query',
484                'generator' => 'search',
485                'gsrsearch' => $term,
486                'gsrnamespace' => implode( '|', $namespaces ),
487                'gsrlimit' => $limit,
488                'gsroffset' => $continue ?: 0,
489                'gsrsort' => $sort,
490                'gsrinfo' => 'totalhits|suggestion',
491                'gsrprop' => 'size|wordcount|timestamp|snippet',
492                'prop' => 'info|categoryinfo',
493                'inprop' => 'url',
494            ] );
495        } else {
496            $filetype = $type;
497            if ( $type === SearchOptions::TYPE_IMAGE ) {
498                $filetype = 'bitmap|drawing';
499            }
500            if ( $type === SearchOptions::TYPE_OTHER ) {
501                $filetype = 'multimedia|office|archive|3d';
502            }
503
504            switch ( $type ) {
505                case SearchOptions::TYPE_VIDEO:
506                    $width = 200;
507                    break;
508
509                case SearchOptions::TYPE_OTHER:
510                    // Generating thumbnails from many of these file types is very
511                    // expensive and slow, enough so that we're better off using a
512                    // larger (takes longer to transfer) pre-generated (but readily
513                    // available) size
514                    $width = min( $this->getThumbLimits() );
515                    break;
516
517                default:
518                    $width = null;
519            }
520
521            // We need to filter out media result with images that have 0 height or width.
522            // This break the API response.
523            $fileres = '';
524            if ( $type !== SearchOptions::TYPE_AUDIO ) {
525                $fileres = '-fileres:0 ';
526            }
527            $request = new FauxRequest( [
528                'format' => 'json',
529                'uselang' => $langCode,
530                'action' => 'query',
531                'generator' => 'search',
532                'gsrsearch' => ( $filetype ? "filetype:$filetype " : '' ) . $fileres . $term,
533                'gsrnamespace' => implode( '|', $namespaces ),
534                'gsrlimit' => $limit,
535                'gsroffset' => $continue ?: 0,
536                'gsrsort' => $sort,
537                'gsrinfo' => 'totalhits|suggestion',
538                'gsrprop' => 'size|wordcount|timestamp|snippet',
539                'prop' => 'info|imageinfo|entityterms',
540                'inprop' => 'url',
541                'iiprop' => 'url|size|mime',
542                'iiurlheight' => $type === SearchOptions::TYPE_IMAGE ? 180 : null,
543                'iiurlwidth' => $width,
544                'wbetterms' => 'label',
545            ] );
546        }
547
548        $externalSearchUri = $this->getConfig()->get( 'MediaSearchExternalSearchUri' );
549        if ( $externalSearchUri ) {
550            // Pull data from Commons: for use in testing
551            $url = $externalSearchUri . '?' . http_build_query( $request->getQueryValues() );
552            $request = MediaWikiServices::getInstance()->getHttpRequestFactory()
553                ->create( $url, [], __METHOD__ );
554            $request->execute();
555            $data = $request->getContent();
556            $response = json_decode( $data, true ) ?: [];
557        } else {
558            // Local results (real)
559            $context = new DerivativeContext( RequestContext::getMain() );
560            $context->setRequest( $request );
561            $this->api->setContext( $context );
562
563            $this->api->execute();
564
565            $response = $this->api->getResult()->getResultData( [], [ 'Strip' => 'all' ] );
566        }
567
568        if ( isset( $response[ 'error' ] ) ) {
569            throw new SearchFailedException();
570        }
571
572        $results = array_values( $response['query']['pages'] ?? [] );
573        $searchinfo = $response['query']['searchinfo'] ?? [];
574        $continue = $response['continue']['gsroffset'] ?? null;
575
576        uasort( $results, static function ( $a, $b ) {
577            return $a['index'] <=> $b['index'];
578        } );
579
580        return [ $results, $searchinfo, $continue ];
581    }
582
583    /**
584     * @param array $queryParams
585     * @return array
586     */
587    protected function getActiveFilters( array $queryParams ): array {
588        return array_intersect_key( $queryParams, array_flip( SearchOptions::ALL_FILTERS ) );
589    }
590
591    /**
592     * Take an associative array of user-specified, supported filter settings
593     * (originally based on their incoming URL params) and ensure that all
594     * provided filters and values are appropriate for the current mediaType.
595     *
596     * @param array $activeFilters
597     * @param string $type
598     * @throws InvalidNamespaceGroupException
599     * @throws InvalidFiltersException
600     */
601    protected function assertValidFilters( array $activeFilters, string $type ) {
602        // Gather a [ key => allowed values ] map of all allowed values for the
603        // given filter and media type
604        $searchOptions = $this->searchOptions->getOptions();
605        $allowedFilterValues = array_map( static function ( $options ) {
606            return array_column( $options['items'], 'value' );
607        }, $searchOptions[ $type ] ?? [] );
608
609        // Filter the list of active filters, throwing out all invalid ones
610        $validFilters = array_filter(
611            $activeFilters,
612            static function ( $value, $key ) use ( $allowedFilterValues ) {
613                return isset( $allowedFilterValues[ $key ] ) && in_array( $value, $allowedFilterValues[ $key ] );
614            },
615            ARRAY_FILTER_USE_BOTH
616        );
617        $invalidFilters = array_diff( $activeFilters, $validFilters );
618
619        // Custom namespace values (e.g. 1|2|3) will not be recognized as
620        // valid input so they'll need special treatment here; if we fail
621        // to derive a list of namespace ids from the input, then it's
622        // invalid; otherwise, we can treat the namespace filter as valid
623        if (
624            isset( $invalidFilters[SearchOptions::FILTER_NAMESPACE] ) &&
625            isset( $allowedFilterValues[SearchOptions::FILTER_NAMESPACE] )
626        ) {
627            $this->searchOptions->getNamespaceIdsFromInput( $activeFilters[SearchOptions::FILTER_NAMESPACE] );
628            unset( $invalidFilters[SearchOptions::FILTER_NAMESPACE] );
629        }
630
631        if ( count( $invalidFilters ) > 0 ) {
632            throw new InvalidFiltersException(
633                'Invalid filters ' . implode( ', ', array_keys( $invalidFilters )
634            ) );
635        }
636    }
637
638    /**
639     * We need to see if the values for each active filter as specified by URL
640     * params match any of the pre-defined possible values for a given filter
641     * type. For example, an imageSize setting determined by url params like
642     * &fileres=500,1000 should be presented to the user as "Medium".
643     *
644     * @param array $activeFilters
645     * @param string $type
646     * @return array
647     */
648    protected function getFiltersForDisplay( $activeFilters, $type ): array {
649        $searchOptions = $this->searchOptions->getOptions();
650
651        // reshape data array into a multi-dimensional [ value => label ] format
652        // per type, so that we can more easily grab the relevant data without
653        // having to loop it every time, for each filter
654        $labels = array_map(
655            static function ( $data ) {
656                return array_column( $data['items'], 'label', 'value' );
657            },
658            $searchOptions[$type] ?? []
659        );
660
661        $display = [];
662        foreach ( $activeFilters as $filter => $value ) {
663            // use label (if found) or fall back to the given value
664            $display[$filter] = $labels[$filter][$value] ?? $value;
665        }
666
667        // Custom namespace filter selection should be displayed as "custom"
668        if (
669            isset( $activeFilters[SearchOptions::FILTER_NAMESPACE] ) &&
670            !in_array(
671                $activeFilters[SearchOptions::FILTER_NAMESPACE],
672                SearchOptions::NAMESPACE_GROUPS
673            )
674        ) {
675            // phpcs:ignore Generic.Files.LineLength.TooLong
676            $display[SearchOptions::FILTER_NAMESPACE] = $labels[SearchOptions::FILTER_NAMESPACE][SearchOptions::NAMESPACES_CUSTOM];
677        }
678
679        return $display;
680    }
681
682    /**
683     * Prepare a string of original search term plus additional filter or sort
684     * parameters, suitable to be passed to the API. If no valid filters are
685     * provided, the original term is returned. Note: Filters are pre-pended
686     * to the search term.
687     *
688     * @param string $term
689     * @param array $filters [ "mimeType" => "tiff", "imageSize" => ">500" ]
690     * @return string "kittens filemime:tiff fileres:>500"
691     * @throws NoCirrusSearchException
692     */
693    protected function getTermWithFilters( $term, $filters ): string {
694        if ( $term === '' || !$filters ) {
695            return $term;
696        }
697
698        // remove filters that aren't supported as search term keyword features;
699        // those will need to be handled elsewhere, differently
700        $validFilters = array_intersect_key( $filters, array_flip( $this->getSearchKeywords() ) );
701
702        $allFilters = '';
703        foreach ( $validFilters as $key => $value ) {
704            $allFilters .= "$key:$value ";
705        }
706
707        $allFilters .= $this->getAssessments( $filters );
708
709        return $allFilters . $term;
710    }
711
712    /**
713     * Prepare a string of assessments, used to generate a search string required for the API.
714     * If assessments are not enabled or empty it will return an empty string
715     *
716     * @param array $filters [ "mimeType" => "tiff", "imageSize" => ">500" ]
717     * @return string "haswbstatement::P6731=Q63348049"
718     */
719    private function getAssessments( $filters ) {
720        // Special handling for "Assessment" filters;
721        // These are transformed into instances of the "haswbstatement:" keyword
722        // using pre-configured wikidata statements
723        $enabledAssessments = $this->getConfig()->get( 'MediaSearchAssessmentFilters' );
724        $allAssessments = '';
725
726        // If assessment filters have been enabled...
727        if ( $enabledAssessments ) {
728            // phpcs:ignore Generic.Files.LineLength.TooLong
729            $assessmentData = $this->searchOptions->getAssessments( SearchOptions::TYPE_IMAGE )[ 'data' ][ 'statementData' ];
730            $validAssessments = array_keys( $enabledAssessments );
731
732            // and if the assessment param matches one of the specified
733            // assessment values
734            if (
735                array_key_exists( SearchOptions::FILTER_ASSESSMENT, $filters ) &&
736                in_array( $filters[ SearchOptions::FILTER_ASSESSMENT ], $validAssessments )
737            ) {
738                $currentAssessment = array_search(
739                    $filters[ SearchOptions::FILTER_ASSESSMENT ],
740                    array_column( $assessmentData, 'value' )
741                );
742
743                $assessmentStatement = $assessmentData[ $currentAssessment ][ 'statement' ];
744                $allAssessments = "$assessmentStatement ";
745            }
746        }
747
748        return $allAssessments;
749    }
750
751    /**
752     * Returns a list of supported search keyword prefixes.
753     *
754     * @return array
755     * @throws NoCirrusSearchException
756     */
757    protected function getSearchKeywords(): array {
758        if ( !$this->searchConfig ) {
759            throw new NoCirrusSearchException( 'CirrusSearch required for search keyword prefixes' );
760        }
761        $features = ( new FullTextKeywordRegistry( $this->searchConfig ) )->getKeywords();
762
763        $keywords = [];
764        foreach ( $features as $feature ) {
765            $keywords = array_merge( $keywords, $feature->getKeywordPrefixes() );
766        }
767        return $keywords;
768    }
769
770    /**
771     * Determine what the API sort value should be
772     *
773     * @param array $activeFilters
774     * @return string
775     */
776    protected function getSort( $activeFilters ): string {
777        if ( array_key_exists( 'sort', $activeFilters ) && $activeFilters[ 'sort' ] === 'recency' ) {
778            return 'create_timestamp_desc';
779        } else {
780            return 'relevance';
781        }
782    }
783
784    /**
785     * Gather a list of thumbnail widths that are frequently requested & are
786     * likely to be warm in that; this is the configured thumbnail limits, and
787     * their responsive 1.5x & 2x versions.
788     *
789     * @return array
790     */
791    protected function getThumbLimits() {
792        $thumbLimits = [];
793        foreach ( $this->getConfig()->get( 'ThumbLimits' ) as $limit ) {
794            $thumbLimits[] = $limit;
795            $thumbLimits[] = $limit * 1.5;
796            $thumbLimits[] = $limit * 2;
797        }
798        $thumbLimits = array_map( 'intval', $thumbLimits );
799        $thumbLimits = array_unique( $thumbLimits );
800        sort( $thumbLimits );
801        return $thumbLimits;
802    }
803
804    /**
805     * Determine what namespaces should be included in the search
806     *
807     * @param array $activeFilters
808     * @param string $type
809     * @return array
810     * @throws InvalidNamespaceGroupException
811     */
812    protected function getSearchNamespaces( array $activeFilters, string $type ) {
813        if ( $type !== SearchOptions::TYPE_PAGE ) {
814            // searches on any tab other than "pages" are specific to NS_FILE
815            return [ NS_FILE ];
816        }
817
818        if ( !isset( $activeFilters[ SearchOptions::FILTER_NAMESPACE ] ) ) {
819            // no custom namespace = defaults to all
820            return array_keys( $this->searchOptions->getNamespaceGroups()[ SearchOptions::NAMESPACES_ALL ] );
821        }
822
823        return $this->searchOptions->getNamespaceIdsFromInput(
824            $activeFilters[ SearchOptions::FILTER_NAMESPACE ]
825        );
826    }
827
828    /**
829     * @param string $suggestion filetype:bitmap|drawing -fileres:0 haswbstatement:P6731=Q63348049 cat
830     * @param array $activeFilters ["assessment" => "featured-image"]
831     * @return string
832     * @throws NoCirrusSearchException
833     */
834    protected function extractSuggestedTerm( $suggestion, $activeFilters ) {
835        $availableFilters = $this->getSearchKeywords();
836        $suggestion = preg_replace(
837            '/(?<=^|\s)(' . implode( '|', $availableFilters ) . '):.+?(?=$|\s)/',
838            ' ',
839            $suggestion
840        );
841
842        $assessments = $this->getAssessments( $activeFilters );
843        $suggestion = str_replace(
844            $assessments,
845            '',
846            $suggestion
847        );
848
849        $suggestion = str_replace(
850            '-fileres:0',
851            '',
852            $suggestion
853        );
854
855        return trim( $suggestion );
856    }
857
858    /**
859     * If the search API returns a suggested search, generate a clickable link
860     * that allows the user to run the suggested query immediately.
861     *
862     * @param array $queryParams
863     * @param string $suggestion
864     * @return string HTML
865     */
866    protected function generateDidYouMeanLink( $queryParams, $suggestion ) {
867        unset( $queryParams[ 'title' ] );
868        $queryParams[ 'search' ] = $suggestion;
869        return $this->linkRenderer->makeLink( $this->getPageTitle(), $suggestion, [], $queryParams );
870    }
871
872    /**
873     * Return formatted data for an individual search result
874     *
875     * @param array $result
876     * @param array $allResults
877     * @param string $type
878     * @return array
879     */
880    protected function getResultData( array $result, array $allResults, $type ): array {
881        // Required context for formatting
882        $thumbLimits = $this->getThumbLimits();
883        $userLanguage = $this->getLanguage();
884
885        // Title
886        $title = Title::newFromDBkey( $result['title'] );
887        $filename = $title ? $title->getText() : $result['title'];
888        $result += [ 'name' => $filename ];
889
890        // Category info.
891        if ( isset( $result['categoryinfo'] ) ) {
892            $categoryInfoParams = [
893                $userLanguage->formatNum( $result['categoryinfo']['size'] ),
894                $userLanguage->formatNum( $result['categoryinfo']['subcats'] ),
895                $userLanguage->formatNum( $result['categoryinfo']['files'] )
896            ];
897            $result += [
898                'categoryInfoText' => $this->msg(
899                    'mediasearch-category-info',
900                    $categoryInfoParams
901                )->text()
902            ];
903        }
904
905        // Namespace prefix.
906        $namespaceId = $title->getNamespace();
907        $mainNsPrefix = preg_replace( '/^[(]?|[)]?$/', '', $this->msg( 'blanknamespace' ) );
908        $result['namespacePrefix'] = $namespaceId === NS_MAIN ?
909            $mainNsPrefix :
910            $this->getContentLanguage()->getFormattedNsText( $namespaceId );
911
912        // Last edited date.
913        $result['lastEdited'] = $userLanguage->timeanddate( $result['timestamp'] );
914
915        // Formatted page size.
916        if ( isset( $result['size'] ) ) {
917            $result['formattedPageSize'] = $userLanguage->formatSize( $result['size'] );
918        }
919
920        // Word count.
921        if ( isset( $result['wordcount'] ) ) {
922            $result['wordcountMessage'] = $this->msg(
923                'mediasearch-wordcount',
924                $userLanguage->formatNum( $result['wordcount'] )
925            )->text();
926        }
927
928        // Formatted image size.
929        if ( isset( $result['imageinfo'] ) && isset( $result['imageinfo'][0]['size'] ) ) {
930            $result['imageSizeMessage'] = $this->msg(
931                'mediasearch-image-size',
932                $userLanguage->formatSize( $result['imageinfo'][0]['size'] )
933            )->text();
934        }
935
936        if (
937            $type === SearchOptions::TYPE_OTHER &&
938            isset( $result['imageinfo'][0]['width'] ) &&
939            isset( $result['imageinfo'][0]['height'] )
940        ) {
941            $result['resolution'] = $userLanguage->formatNum( $result['imageinfo'][0]['width'] ) .
942                ' × ' . $userLanguage->formatNum( $result['imageinfo'][0]['height'] );
943        }
944
945        if ( isset( $result['imageinfo'][0]['thumburl'] ) ) {
946            $imageInfo = $result['imageinfo'][0];
947            $oldWidth = $imageInfo['thumbwidth'];
948            $newWidth = $oldWidth;
949
950            // find the closest (larger) width that is more common, it is (much) more
951            // likely to have a thumbnail cached
952            foreach ( $thumbLimits as $commonWidth ) {
953                if ( $commonWidth >= $oldWidth ) {
954                    $newWidth = $commonWidth;
955                    break;
956                }
957            }
958
959            $imageInfo['thumburl'] = str_replace(
960                '/' . $oldWidth . 'px-',
961                '/' . $newWidth . 'px-',
962                $imageInfo['thumburl']
963            );
964
965            $result['imageResultClass'] = 'sdms-image-result';
966
967            if (
968                $imageInfo['thumbwidth'] && $imageInfo['thumbheight'] &&
969                is_numeric( $imageInfo['thumbwidth'] ) && is_numeric( $imageInfo['thumbheight'] ) &&
970                $imageInfo['thumbheight'] > 0
971            ) {
972                if ( (int)$imageInfo['thumbwidth'] / (int)$imageInfo['thumbheight'] < 1 ) {
973                    $result['imageResultClass'] .= ' sdms-image-result--portrait';
974                }
975
976                // Generate style attribute for image wrapper.
977                $displayWidth = $imageInfo['thumbwidth'];
978                if ( $imageInfo['thumbheight'] < 180 ) {
979                    // For small images, set the wrapper width to the
980                    // thumbnail width plus a little extra to simulate
981                    // left/right padding.
982                    $displayWidth += 60;
983                }
984                // Set max initial width of 350px.
985                $result['wrapperStyle'] = 'width: ' . min( [ $displayWidth, 350 ] ) . 'px;';
986            }
987
988            if ( count( $allResults ) <= 3 ) {
989                $result['imageResultClass'] .= ' sdms-image-result--limit-size';
990            }
991
992            if ( $imageInfo[ 'mime' ] ) {
993                $result[ 'extension' ] = $imageInfo[ 'mime' ];
994            }
995
996            // Generate style attribute for the image itself.
997            // There are height and max-width rules with the important
998            // keyword for .content a > img in Minerva Neue, and they
999            // have to be overridden.
1000            if ( $imageInfo['width'] && $imageInfo['height'] ) {
1001                $result['imageStyle'] =
1002                    'height: 100% !important; ' .
1003                    'max-width: ' . $imageInfo['width'] . 'px !important; ' .
1004                    'max-height: ' . $imageInfo['height'] . 'px;';
1005            }
1006        }
1007
1008        return $result;
1009    }
1010}