Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.01% covered (warning)
88.01%
257 / 292
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchOptions
88.01% covered (warning)
88.01%
257 / 292
41.67% covered (danger)
41.67%
5 / 12
46.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getInstanceFromContext
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getSearchOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getOptions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getImageSizes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 getMimeTypes
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
1 / 1
8
 getAssessments
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
8.02
 getSorts
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getLicenseGroups
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.02
 getNamespaces
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 getNamespaceGroups
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespaceIdsFromInput
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\MediaSearch;
4
5use InvalidArgumentException;
6use MediaWiki\Config\Config;
7use MediaWiki\Config\ConfigException;
8use MediaWiki\MediaWikiServices;
9use MessageLocalizer;
10use Wikibase\Search\Elastic\Query\HasLicenseFeature;
11
12/**
13 * @license GPL-2.0-or-later
14 */
15class SearchOptions {
16
17    public const TYPE_IMAGE = 'image';
18    public const TYPE_AUDIO = 'audio';
19    public const TYPE_VIDEO = 'video';
20    public const TYPE_PAGE = 'page';
21    public const TYPE_OTHER = 'other';
22
23    public const ALL_TYPES = [
24        self::TYPE_IMAGE,
25        self::TYPE_AUDIO,
26        self::TYPE_VIDEO,
27        self::TYPE_PAGE,
28        self::TYPE_OTHER,
29    ];
30
31    public const FILTER_MIME = 'filemime';
32    public const FILTER_SIZE = 'fileres';
33    public const FILTER_ASSESSMENT = 'assessment';
34    public const FILTER_LICENSE = 'haslicense';
35    public const FILTER_SORT = 'sort';
36    public const FILTER_NAMESPACE = 'namespace';
37
38    public const ALL_FILTERS = [
39        self::FILTER_MIME,
40        self::FILTER_SIZE,
41        self::FILTER_ASSESSMENT,
42        self::FILTER_LICENSE,
43        self::FILTER_SORT,
44        self::FILTER_NAMESPACE,
45    ];
46
47    public const NAMESPACES_ALL = 'all';
48    public const NAMESPACES_ALL_INCL_FILE = 'all_incl_file';
49    public const NAMESPACES_DISCUSSION = 'discussion';
50    public const NAMESPACES_HELP = 'help';
51    public const NAMESPACES_CUSTOM = 'custom';
52
53    public const NAMESPACE_GROUPS = [
54        self::NAMESPACES_ALL,
55        self::NAMESPACES_ALL_INCL_FILE,
56        self::NAMESPACES_DISCUSSION,
57        self::NAMESPACES_HELP,
58        self::NAMESPACES_CUSTOM
59    ];
60
61    /** @var MessageLocalizer */
62    private $context;
63
64    /** @var Config|null */
65    private $mainConfig;
66
67    /** @var Config|null */
68    private $searchConfig;
69
70    /**
71     * @param MessageLocalizer $context
72     * @param Config|null $mainConfig
73     * @param Config|null $searchConfig
74     */
75    public function __construct(
76        MessageLocalizer $context,
77        Config $mainConfig = null,
78        Config $searchConfig = null
79    ) {
80        $this->context = $context;
81        $this->mainConfig = $mainConfig;
82        $this->searchConfig = $searchConfig;
83    }
84
85    /**
86     * @param MessageLocalizer $context
87     * @return SearchOptions
88     */
89    public static function getInstanceFromContext( MessageLocalizer $context ) {
90        $configFactory = MediaWikiServices::getInstance()->getConfigFactory();
91
92        try {
93            $mainConfig = $configFactory->makeConfig( 'main' );
94        } catch ( ConfigException $e ) {
95            $mainConfig = null;
96        }
97
98        try {
99            $searchConfig = $configFactory->makeConfig( 'WikibaseCirrusSearch' );
100        } catch ( ConfigException $e ) {
101            $searchConfig = null;
102        }
103
104        return new static( $context, $mainConfig, $searchConfig );
105    }
106
107    /**
108     * @param MessageLocalizer $context
109     * @return array
110     */
111    public static function getSearchOptions( MessageLocalizer $context ): array {
112        $instance = static::getInstanceFromContext( $context );
113        return $instance->getOptions();
114    }
115
116    /**
117     * Generate an associative array that combines all options for filter,
118     * sort, and licenses for all media types, with labels in the appropriate
119     * language.
120     *
121     * @return array
122     */
123    public function getOptions(): array {
124        $searchOptions = [];
125        // Some options are only present for certain media types.
126        // The methods which generate type-specific options take a mediatype
127        // argument and will return false if the given type does not support the
128        // options in question.
129
130        // The $context object must be passed down to the various helper methods
131        // because getSearchOptions can be called both as a ResourceLoader callback
132        // as well as during SpecialMediaSearch->execute(); we need to make sure
133        // the messages can be internationalized in the same way regardless
134        foreach ( static::ALL_TYPES as $type ) {
135            $searchOptions[ $type ] = array_filter( [
136                static::FILTER_LICENSE => $this->getLicenseGroups( $type ),
137                static::FILTER_MIME => $this->getMimeTypes( $type ),
138                static::FILTER_SIZE => $this->getImageSizes( $type ),
139                static::FILTER_ASSESSMENT => $this->getAssessments( $type ),
140                static::FILTER_NAMESPACE => $this->getNamespaces( $type ),
141                static::FILTER_SORT => $this->getSorts( $type )
142            ] );
143        }
144
145        return $searchOptions;
146    }
147
148    /**
149     * Get the size options. Only supported by "image" type.
150     *
151     * @param string $type
152     * @return array
153     * @throws InvalidArgumentException
154     */
155    public function getImageSizes( string $type ): array {
156        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
157            throw new InvalidArgumentException( "$type is not a valid type" );
158        }
159
160        if ( $type === static::TYPE_IMAGE ) {
161            return [ 'items' => [
162                [
163                    'label' => $this->context->msg( 'mediasearch-filter-size-unset' )->text(),
164                    'value' => ''
165                ],
166                [
167                    'label' => $this->context->msg( 'mediasearch-filter-size-small' )->text(),
168                    'value' => '<500'
169                ],
170                [
171                    // phpcs:ignore Generic.Files.LineLength.TooLong
172                    'label' => $this->context->msg( 'mediasearch-filter-size-medium' )->text(),
173                    'value' => '500,1000'
174                ],
175                [
176                    'label' => $this->context->msg( 'mediasearch-filter-size-large' )->text(),
177                    'value' => '>1000'
178                ],
179            ] ];
180        } else {
181            return [];
182        }
183    }
184
185    /**
186     * Get the mimetype options for a given mediatype. All types except "page"
187     * support this option.
188     *
189     * @param string $type
190     * @return array
191     */
192    public function getMimeTypes( string $type ): array {
193        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
194            throw new InvalidArgumentException( "$type is not a valid type" );
195        }
196
197        switch ( $type ) {
198            case static::TYPE_IMAGE:
199                return [ 'items' => [
200                    [
201                        // phpcs:ignore Generic.Files.LineLength.TooLong
202                        'label' => $this->context->msg( 'mediasearch-filter-file-type-unset' )->text(),
203                        'value' => ''
204                    ],
205                    [
206                        'label' => 'tiff',
207                        'value' => 'tiff'
208                    ],
209                    [
210                        'label' => 'png',
211                        'value' => 'png'
212                    ],
213                    [
214                        'label' => 'gif',
215                        'value' => 'gif'
216                    ],
217                    [
218                        'label' => 'jpg',
219                        'value' => 'jpeg'
220                    ],
221                    [
222                        'label' => 'webp',
223                        'value' => 'webp'
224                    ],
225                    [
226                        'label' => 'xcf',
227                        'value' => 'xcf'
228                    ],
229                    [
230                        'label' => 'svg',
231                        'value' => 'svg'
232                    ]
233                ] ];
234            case static::TYPE_AUDIO:
235                return [ 'items' => [
236                    [
237                        // phpcs:ignore Generic.Files.LineLength.TooLong
238                        'label' => $this->context->msg( 'mediasearch-filter-file-type-unset' )->text(),
239                        'value' => ''
240                    ],
241                    [
242                        'label' => 'mid',
243                        'value' => 'midi'
244                    ],
245                    [
246                        'label' => 'flac',
247                        'value' => 'flac'
248                    ],
249                    [
250                        'label' => 'wav',
251                        'value' => 'wav'
252                    ],
253                    [
254                        'label' => 'mp3',
255                        'value' => 'mpeg'
256                    ],
257                    [
258                        'label' => 'ogg',
259                        'value' => 'ogg'
260                    ]
261                ] ];
262            case static::TYPE_VIDEO:
263                return [ 'items' => [
264                    [
265                        // phpcs:ignore Generic.Files.LineLength.TooLong
266                        'label' => $this->context->msg( 'mediasearch-filter-file-type-unset' )->text(),
267                        'value' => ''
268                    ],
269                    [
270                        'label' => 'webm',
271                        'value' => 'webm'
272                    ],
273                    [
274                        'label' => 'mpg',
275                        'value' => 'mpeg'
276                    ],
277                    [
278                        'label' => 'ogg',
279                        'value' => 'ogg'
280                    ]
281                ] ];
282            case static::TYPE_OTHER:
283                return [ 'items' => [
284                    [
285                        // phpcs:ignore Generic.Files.LineLength.TooLong
286                        'label' => $this->context->msg( 'mediasearch-filter-file-type-unset' )->text(),
287                        'value' => ''
288                    ],
289                    [
290                        'label' => 'pdf',
291                        'value' => 'pdf'
292                    ],
293                    [
294                        'label' => 'djvu',
295                        'value' => 'djvu'
296                    ],
297                    [
298                        'label' => 'stl',
299                        'value' => 'sla'
300                    ]
301                ] ];
302            case static::TYPE_PAGE:
303            default:
304                return [];
305        }
306    }
307
308    /**
309     * Get the assessment options (only applicable for image and video types) based on configuration
310     *
311     * @param string $type
312     * @return array [ 'items' => [], 'data' => [] ]
313     */
314    public function getAssessments( string $type ): array {
315        $assessmentOptions = [];
316        $assessmentData = [];
317
318        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
319            throw new InvalidArgumentException( "$type is not a valid type" );
320        }
321
322        // Bail early and return empty if we can't access config vars for some reason;
323        // Feature will simply not be enabled in this case
324        if ( $this->mainConfig ) {
325            $assessmentConfig = $this->mainConfig->get( 'MediaSearchAssessmentFilters' );
326        } else {
327            return [];
328        }
329
330        // If we have the appropriate config data and we are on an image or video tab,
331        // build a data structure for the pre-definied assessment types and
332        // labels, along with their corresponding wikidata statements
333        if ( $assessmentConfig && ( $type === static::TYPE_IMAGE || $type === self::TYPE_VIDEO ) ) {
334            // Start with the default label
335            $assessmentOptions[] = [
336                'label' => $this->context->msg( 'mediasearch-filter-assessment-unset' )->text(),
337                'value' => ''
338            ];
339
340            // Options/labels
341            foreach ( $assessmentConfig as $key => $statement ) {
342                $assessmentOptions[] = [
343                    // All i18n labels are assumed to be prefixed with mediasearch-filter-assessment-
344                    'label' => $this->context->msg( 'mediasearch-filter-assessment-' . $key )->text(),
345                    'value' => $key
346                ];
347            }
348
349            // WB statements
350            foreach ( $assessmentConfig as $key => $statement ) {
351                $assessmentData[] = [
352                    'value' => $key,
353                    'statement' => 'haswbstatement:' . $statement
354                ];
355            }
356
357            return [
358                'items' => $assessmentOptions,
359                'data' => [
360                    'statementData' => $assessmentData
361                ]
362            ];
363        } else {
364            return [];
365        }
366    }
367
368    /**
369     * Get the sort options for each media type. Supported by all types.
370     *
371     * @param string $type
372     * @return array
373     */
374    public function getSorts( string $type ): array {
375        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
376            throw new InvalidArgumentException( "$type is not a valid type" );
377        }
378
379        return [ 'items' => [
380            [
381                'label' => $this->context->msg( 'mediasearch-filter-sort-default' )->text(),
382                'value' => ''
383            ],
384            [
385                'label' => $this->context->msg( 'mediasearch-filter-sort-recency' )->text(),
386                'value' => 'recency'
387            ]
388        ] ];
389    }
390
391    /**
392     * Parse the on-wiki license mapping page (if one exists) and return an
393     * array of arrays, structured like:
394     * [ [ 'label' => 'some-label-text', 'value' => 'cc-by' ] ]
395     * With one child array for each license group defined in the mapping.
396     *
397     * Supported by all types except "page" type.
398     *
399     * @param string $type
400     * @return array
401     */
402    public function getLicenseGroups( string $type ): array {
403        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
404            throw new InvalidArgumentException( "$type is not a valid type" );
405        }
406
407        if (
408            $this->searchConfig === null ||
409            !method_exists( HasLicenseFeature::class, 'getConfiguredLicenseMap' )
410        ) {
411            // This feature requires a dependency: not installed = feature not supported
412            return [];
413        }
414
415        // Category & page searches do not have license filters
416        if ( $type === static::TYPE_PAGE ) {
417            return [];
418        }
419
420        $licenseMappings = HasLicenseFeature::getConfiguredLicenseMap( $this->searchConfig );
421        if ( !$licenseMappings ) {
422            return [];
423        }
424
425        $licenseGroups = [];
426
427        // Add the default label
428        $licenseGroups[] = [
429            'label' => $this->context->msg( 'mediasearch-filter-license-any' )->text(),
430            'value' => ''
431        ];
432
433        foreach ( array_keys( $licenseMappings ) as $group ) {
434            $msgKey = 'mediasearch-filter-license-' . $group;
435
436            $licenseGroups[] = [
437                'label' => $this->context->msg( $msgKey )->text(),
438                'value' => $group
439            ];
440        }
441
442        // Add the "other" label
443        $licenseGroups[] = [
444            'label' => $this->context->msg( 'mediasearch-filter-license-other' )->text(),
445            'value' => 'other'
446        ];
447
448        return [ 'items' => $licenseGroups ];
449    }
450
451    /**
452     * Get the namespace options. Only supported by "page" type.
453     *
454     * @param string $type
455     * @return array
456     */
457    public function getNamespaces( string $type ): array {
458        if ( !in_array( $type, static::ALL_TYPES, true ) ) {
459            throw new InvalidArgumentException( "$type is not a valid type" );
460        }
461
462        if ( $type === static::TYPE_PAGE ) {
463            $filterItems = [
464                [
465                    // phpcs:ignore Generic.Files.LineLength.TooLong
466                    'label' => $this->context->msg( 'mediasearch-filter-namespace-all' )->text(),
467                    'value' => static::NAMESPACES_ALL
468                ],
469                [
470                    // phpcs:ignore Generic.Files.LineLength.TooLong
471                    'label' => $this->context->msg( 'mediasearch-filter-namespace-discussion' )->text(),
472                    'value' => static::NAMESPACES_DISCUSSION
473                ],
474                [
475                    // phpcs:ignore Generic.Files.LineLength.TooLong
476                    'label' => $this->context->msg( 'mediasearch-filter-namespace-help' )->text(),
477                    'value' => static::NAMESPACES_HELP
478                ],
479                [
480                    // phpcs:ignore Generic.Files.LineLength.TooLong
481                    'label' => $this->context->msg( 'mediasearch-filter-namespace-custom' )->text(),
482                    'value' => static::NAMESPACES_CUSTOM
483                ],
484            ];
485
486            return [
487                'items' => $filterItems,
488                'data' => [
489                    'namespaceGroups' => $this->getNamespaceGroups()
490                ],
491            ];
492        } else {
493            return [];
494        }
495    }
496
497    /**
498     * Get namespace data for the different namespace filter groups.
499     *
500     * @return array
501     */
502    public function getNamespaceGroups(): array {
503        $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
504        $allNamespaces = $namespaceInfo->getCanonicalNamespaces();
505
506        // $wgNamespacesToBeSearchedDefault is an array with namespace ids as keys, and 1|0 to
507        // indicate if the namespace should be searched by default
508        if ( $this->mainConfig->get( 'NamespacesToBeSearchedDefault' ) ) {
509            $defaultSearchNamespaces = array_filter(
510                $this->mainConfig->get( 'NamespacesToBeSearchedDefault' )
511            );
512        } else {
513            $defaultSearchNamespaces = [ 0 => 1 ];
514        }
515
516        $realNamespaces = array_filter(
517            $allNamespaces,
518            static function ( $namespaceId ) {
519                // Exclude virtual namespaces
520                return $namespaceId >= 0;
521            },
522            ARRAY_FILTER_USE_KEY
523        );
524        $nonFileNamespaces = array_filter(
525            $realNamespaces,
526            static function ( $namespaceId ) {
527                return $namespaceId !== NS_FILE;
528            },
529            ARRAY_FILTER_USE_KEY
530        );
531
532        $customNamespaces = array_intersect_key( $nonFileNamespaces, $defaultSearchNamespaces );
533
534        $talkNamespaces = array_combine(
535            $namespaceInfo->getTalkNamespaces(),
536            array_map( static function ( $namespaceId ) use ( $allNamespaces ) {
537                return $allNamespaces[$namespaceId];
538            }, $namespaceInfo->getTalkNamespaces() )
539        );
540
541        return [
542            static::NAMESPACES_ALL_INCL_FILE => $realNamespaces,
543            static::NAMESPACES_ALL => $nonFileNamespaces,
544            static::NAMESPACES_DISCUSSION => $talkNamespaces,
545            static::NAMESPACES_HELP => [
546                NS_PROJECT => $namespaceInfo->getCanonicalName( NS_PROJECT ),
547                NS_HELP => $namespaceInfo->getCanonicalName( NS_HELP ),
548            ],
549            static::NAMESPACES_CUSTOM => $customNamespaces
550        ];
551    }
552
553    /**
554     * @param string $input
555     * @return int[]
556     * @throws InvalidNamespaceGroupException
557     */
558    public function getNamespaceIdsFromInput( $input ): array {
559        $namespaceGroups = $this->getNamespaceGroups();
560
561        if ( isset( $namespaceGroups[$input] ) ) {
562            // namespace is one of the predefined namespace categories
563            // for which we have a list of namespace ids handy
564            return array_keys( $namespaceGroups[$input] );
565        }
566
567        $inputIds = explode( '|', $input );
568        $allowedIds = array_keys( $namespaceGroups[ static::NAMESPACES_ALL_INCL_FILE ] );
569        $verifiedIds = array_intersect( $allowedIds, $inputIds );
570        if ( count( $verifiedIds ) === count( $inputIds ) ) {
571            return $verifiedIds;
572        }
573
574        throw new InvalidNamespaceGroupException( "$input is no valid namespace input" );
575    }
576}