Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 490 |
|
0.00% |
0 / 20 |
CRAP | |
0.00% |
0 / 1 |
SpecialMediaSearch | |
0.00% |
0 / 490 |
|
0.00% |
0 / 20 |
11772 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 182 |
|
0.00% |
0 / 1 |
210 | |||
findExactMatchRedirectUrl | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
redirectOnExactMatch | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getType | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
30 | |||
search | |
0.00% |
0 / 80 |
|
0.00% |
0 / 1 |
272 | |||
getActiveFilters | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
assertValidFilters | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 | |||
getFiltersForDisplay | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
getTermWithFilters | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
getAssessments | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
20 | |||
getSearchKeywords | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getSort | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
getThumbLimits | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
getSearchNamespaces | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
extractSuggestedTerm | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
generateDidYouMeanLink | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getResultData | |
0.00% |
0 / 73 |
|
0.00% |
0 / 1 |
650 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\MediaSearch\Special; |
4 | |
5 | use ApiBase; |
6 | use ApiMain; |
7 | use CirrusSearch\Parser\FullTextKeywordRegistry; |
8 | use CirrusSearch\SearchConfig; |
9 | use DerivativeContext; |
10 | use MediaWiki\Config\Config; |
11 | use MediaWiki\Config\ConfigException; |
12 | use MediaWiki\Extension\MediaSearch\InvalidFiltersException; |
13 | use MediaWiki\Extension\MediaSearch\InvalidNamespaceGroupException; |
14 | use MediaWiki\Extension\MediaSearch\NoCirrusSearchException; |
15 | use MediaWiki\Extension\MediaSearch\SearchFailedException; |
16 | use MediaWiki\Extension\MediaSearch\SearchOptions; |
17 | use MediaWiki\Html\TemplateParser; |
18 | use MediaWiki\Linker\LinkRenderer; |
19 | use MediaWiki\MediaWikiServices; |
20 | use MediaWiki\Output\OutputPage; |
21 | use MediaWiki\Request\FauxRequest; |
22 | use MediaWiki\SiteStats\SiteStats; |
23 | use MediaWiki\SpecialPage\SpecialPage; |
24 | use MediaWiki\Title\NamespaceInfo; |
25 | use MediaWiki\Title\Title; |
26 | use MediaWiki\User\Options\UserOptionsManager; |
27 | use OOUI\Tag; |
28 | use RequestContext; |
29 | use SearchEngine; |
30 | use SearchEngineFactory; |
31 | use Wikimedia\Assert\Assert; |
32 | |
33 | /** |
34 | * Special page specifically for searching multimedia pages. |
35 | */ |
36 | class 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 | } |