Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.03% covered (warning)
88.03%
125 / 142
64.29% covered (warning)
64.29%
27 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
Gadget
88.03% covered (warning)
88.03%
125 / 142
64.29% covered (warning)
64.29%
27 / 42
111.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
23
 serializeDefinition
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 newEmptyGadget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isValidGadgetID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDescriptionMessageKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRawDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSection
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isOnByDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPackaged
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 isActionSupported
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isNamespaceSupported
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isCategorySupported
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isSkinSupported
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isContentModelSupported
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 supportsUrlLoad
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsResourceLoader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requiresES6
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 getDefinition
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScripts
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getStyles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getJSONs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getScriptsAndStyles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLegacyScripts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getDependencies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPeers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequiredRights
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredActions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequiredCategories
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredSkins
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredContentModels
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 getValidationWarnings
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
12.42
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\Gadgets;
22
23use InvalidArgumentException;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Permissions\Authority;
26use MediaWiki\ResourceLoader\ResourceLoader;
27use MediaWiki\Skin\Skin;
28use MediaWiki\User\UserIdentity;
29
30/**
31 * Represents one gadget definition.
32 *
33 * @copyright 2007 Daniel Kinzler
34 */
35class Gadget {
36    /**
37     * Increment this when changing class structure
38     */
39    public const GADGET_CLASS_VERSION = 19;
40
41    public const CACHE_TTL = 86400;
42
43    /** @var string[] */
44    private $dependencies = [];
45    /** @var string[] */
46    private array $pages = [];
47    /** @var string[] */
48    private $peers = [];
49    /** @var string[] */
50    private $messages = [];
51    /** @var string|null */
52    private $name;
53    /** @var string|null */
54    private $definition = null;
55    private bool $resourceLoaded = false;
56    private bool $requiresES6 = false;
57    /** @var string[] */
58    private array $requiredRights = [];
59    /** @var string[] */
60    private array $requiredActions = [];
61    /** @var string[] */
62    private array $requiredSkins = [];
63    /** @var int[]|string[] */
64    private array $requiredNamespaces = [];
65    /** @var string[] */
66    private array $requiredCategories = [];
67    /** @var string[] */
68    private array $requiredContentModels = [];
69    /** @var bool */
70    private $onByDefault = false;
71    /** @var bool */
72    private $hidden = false;
73    /** @var bool */
74    private $package = false;
75    /** @var string */
76    private $type = '';
77    private string $section = '';
78    /** @var bool */
79    private $supportsUrlLoad = false;
80
81    public function __construct( array $options ) {
82        foreach ( $options as $member => $option ) {
83            switch ( $member ) {
84                case 'section':
85                case 'definition':
86                case 'dependencies':
87                case 'hidden':
88                case 'messages':
89                case 'name':
90                case 'onByDefault':
91                case 'package':
92                case 'pages':
93                case 'peers':
94                case 'requiredActions':
95                case 'requiredCategories':
96                case 'requiredContentModels':
97                case 'requiredNamespaces':
98                case 'requiredRights':
99                case 'requiredSkins':
100                case 'requiresES6':
101                case 'resourceLoaded':
102                case 'supportsUrlLoad':
103                case 'type':
104                    $this->{$member} = $option;
105                    break;
106                default:
107                    throw new InvalidArgumentException( "Unrecognized '$member' parameter" );
108            }
109        }
110    }
111
112    /**
113     * Create a serialized array based on the metadata in a GadgetDefinitionContent object,
114     * from which a Gadget object can be constructed.
115     *
116     * @param string $id
117     * @param array $data
118     * @return array
119     */
120    public static function serializeDefinition( string $id, array $data ): array {
121        $prefixGadgetNs = static function ( $page ) {
122            return GadgetRepo::RESOURCE_TITLE_PREFIX . $page;
123        };
124        return [
125            'section' => $data['settings']['section'],
126            'dependencies' => $data['module']['dependencies'],
127            'hidden' => $data['settings']['hidden'],
128            'messages' => $data['module']['messages'],
129            'name' => $id,
130            'onByDefault' => $data['settings']['default'],
131            'package' => $data['settings']['package'],
132            'pages' => array_map( $prefixGadgetNs, $data['module']['pages'] ),
133            'peers' => $data['module']['peers'],
134            'requiredActions' => $data['settings']['actions'],
135            'requiredCategories' => $data['settings']['categories'],
136            'requiredContentModels' => $data['settings']['contentModels'],
137            'requiredNamespaces' => $data['settings']['namespaces'],
138            'requiredRights' => $data['settings']['rights'],
139            'requiredSkins' => $data['settings']['skins'],
140            'requiresES6' => $data['settings']['requiresES6'],
141            'resourceLoaded' => true,
142            'supportsUrlLoad' => $data['settings']['supportsUrlLoad'],
143            'type' => $data['module']['type'],
144        ];
145    }
146
147    /**
148     * Serialize to an array
149     */
150    public function toArray(): array {
151        return [
152            'section' => $this->section,
153            'dependencies' => $this->dependencies,
154            'hidden' => $this->hidden,
155            'messages' => $this->messages,
156            'name' => $this->name,
157            'onByDefault' => $this->onByDefault,
158            'package' => $this->package,
159            'pages' => $this->pages,
160            'peers' => $this->peers,
161            'requiredActions' => $this->requiredActions,
162            'requiredCategories' => $this->requiredCategories,
163            'requiredContentModels' => $this->requiredContentModels,
164            'requiredNamespaces' => $this->requiredNamespaces,
165            'requiredRights' => $this->requiredRights,
166            'requiredSkins' => $this->requiredSkins,
167            'requiresES6' => $this->requiresES6,
168            'resourceLoaded' => $this->resourceLoaded,
169            'supportsUrlLoad' => $this->supportsUrlLoad,
170            'type' => $this->type,
171            // Legacy  (specific to MediaWikiGadgetsDefinitionRepo)
172            'definition' => $this->definition,
173        ];
174    }
175
176    /**
177     * Get a placeholder object to use if a gadget doesn't exist
178     *
179     * @param string $id name
180     * @return self
181     */
182    public static function newEmptyGadget( $id ) {
183        return new self( [ 'name' => $id ] );
184    }
185
186    /**
187     * Whether the provided gadget id is valid
188     *
189     * @param string $id
190     * @return bool
191     */
192    public static function isValidGadgetID( $id ) {
193        return $id !== '' && ResourceLoader::isValidModuleName( self::getModuleName( $id ) );
194    }
195
196    /**
197     * @return string Gadget name
198     */
199    public function getName() {
200        return $this->name;
201    }
202
203    /**
204     * @return string Message key
205     */
206    public function getDescriptionMessageKey() {
207        return 'gadget-' . $this->name;
208    }
209
210    /**
211     * @return string Gadget description parsed into HTML
212     */
213    public function getDescription() {
214        return wfMessage( $this->getDescriptionMessageKey() )->parse();
215    }
216
217    /**
218     * @return string Wikitext of gadget description
219     */
220    public function getRawDescription() {
221        return wfMessage( $this->getDescriptionMessageKey() )->plain();
222    }
223
224    /**
225     * @return string Name of section our gadget belongs to. Empty string if none.
226     */
227    public function getSection(): string {
228        return $this->section;
229    }
230
231    /**
232     * @param string $id Name of gadget
233     * @return string Name of ResourceLoader module for the gadget
234     */
235    public static function getModuleName( $id ) {
236        return "ext.gadget.{$id}";
237    }
238
239    /**
240     * Checks whether this gadget is enabled for given user
241     *
242     * @param UserIdentity $user user to check against
243     * @return bool
244     */
245    public function isEnabled( UserIdentity $user ) {
246        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
247        return (bool)$userOptionsLookup->getOption( $user, "gadget-{$this->name}", $this->onByDefault );
248    }
249
250    /**
251     * Checks whether a given user may enable this gadget
252     *
253     * @param Authority $user The user to check against
254     * @return bool
255     */
256    public function isAllowed( Authority $user ) {
257        return !$this->requiredRights || $user->isAllowedAll( ...$this->requiredRights );
258    }
259
260    /**
261     * @return bool Whether this gadget is on by default for everyone
262     *  (but can be disabled in preferences)
263     */
264    public function isOnByDefault() {
265        return $this->onByDefault;
266    }
267
268    /**
269     * @return bool
270     */
271    public function isHidden() {
272        return $this->hidden;
273    }
274
275    public function isPackaged(): bool {
276        // A packaged gadget needs to have a main script, so there must be at least one script
277        return $this->package && $this->supportsResourceLoader() && $this->getScripts();
278    }
279
280    /**
281     * Whether to load the gadget on a given page action.
282     *
283     * @param string $action Action name
284     * @return bool
285     */
286    public function isActionSupported( string $action ): bool {
287        if ( !$this->requiredActions ) {
288            return true;
289        }
290        // Don't require specifying 'submit' action in addition to 'edit'
291        if ( $action === 'submit' ) {
292            $action = 'edit';
293        }
294        return in_array( $action, $this->requiredActions, true );
295    }
296
297    /**
298     * Whether to load the gadget on pages in a given namespace ID.
299     *
300     * @param int $namespace Namespace ID
301     * @return bool
302     */
303    public function isNamespaceSupported( int $namespace ) {
304        // This is intentionally a non-strict in_array() because
305        // MediaWikiGadgetsDefinitionRepo sets numerical strings.
306        return !$this->requiredNamespaces || in_array( $namespace, $this->requiredNamespaces );
307    }
308
309    /**
310     * Whether to load the gadget on pages in any of the given categories
311     *
312     * @param array $categories Category names (category title text, no namespace prefix, no dbkey-underscores)
313     * @return bool
314     */
315    public function isCategorySupported( array $categories ) {
316        if ( !$this->requiredCategories ) {
317            return true;
318        }
319        foreach ( $categories as $category ) {
320            if ( in_array( $category, $this->requiredCategories, true ) ) {
321                return true;
322            }
323        }
324        return false;
325    }
326
327    /**
328     * Check if this gadget is compatible with a skin
329     *
330     * @param Skin $skin
331     * @return bool
332     */
333    public function isSkinSupported( Skin $skin ) {
334        return !$this->requiredSkins || in_array( $skin->getSkinName(), $this->requiredSkins, true );
335    }
336
337    /**
338     * Check if this gadget is compatible with the given content model
339     *
340     * @param string $contentModel The content model ID
341     * @return bool
342     */
343    public function isContentModelSupported( string $contentModel ) {
344        return !$this->requiredContentModels || in_array( $contentModel, $this->requiredContentModels );
345    }
346
347    /**
348     * @return bool Whether the gadget can be loaded with `?withgadget` query parameter.
349     */
350    public function supportsUrlLoad() {
351        return $this->supportsUrlLoad;
352    }
353
354    /**
355     * @return bool Whether all of this gadget's JS components support ResourceLoader
356     */
357    public function supportsResourceLoader() {
358        return $this->resourceLoaded;
359    }
360
361    /**
362     * @return bool Whether this gadget requires ES6
363     */
364    public function requiresES6(): bool {
365        return $this->requiresES6 && !$this->onByDefault;
366    }
367
368    /**
369     * @return bool Whether this gadget has resources that can be loaded via ResourceLoader
370     */
371    public function hasModule() {
372        return $this->getStyles() || ( $this->supportsResourceLoader() && $this->getScripts() );
373    }
374
375    /**
376     * @return string|null Definition for this gadget from MediaWiki:Gadgets-definition,
377     *  or null if MediaWikiGadgetsJsonRepo is used.
378     */
379    public function getDefinition() {
380        return $this->definition;
381    }
382
383    /**
384     * @return string[] JS page names (including namespace)
385     */
386    public function getScripts() {
387        return array_values( array_filter( $this->pages, static function ( $page ) {
388            return str_ends_with( $page, '.js' );
389        } ) );
390    }
391
392    /**
393     * @return string[] CSS page names (including namespace)
394     */
395    public function getStyles() {
396        return array_values( array_filter( $this->pages, static function ( $page ) {
397            return str_ends_with( $page, '.css' );
398        } ) );
399    }
400
401    /**
402     * @return string[] JSON page names (including namespace)
403     */
404    public function getJSONs(): array {
405        return array_values( array_filter( $this->pages, static function ( $page ) {
406            return str_ends_with( $page, '.json' );
407        } ) );
408    }
409
410    /**
411     * @return string[] All page names for this gadget's resources
412     */
413    public function getScriptsAndStyles() {
414        return array_merge( $this->getScripts(), $this->getStyles(), $this->getJSONs() );
415    }
416
417    /**
418     * Returns list of scripts that don't support ResourceLoader
419     * @return string[]
420     */
421    public function getLegacyScripts() {
422        return $this->supportsResourceLoader() ? [] : $this->getScripts();
423    }
424
425    /**
426     * Returns names of resources this gadget depends on
427     * @return string[]
428     */
429    public function getDependencies() {
430        return $this->dependencies;
431    }
432
433    /**
434     * Get list of extra modules that should be loaded when this gadget is enabled
435     *
436     * Primary use case is to allow a Gadget that includes JavaScript to also load
437     * a (usually, hidden) styles-type module to be applied to the page. Dependencies
438     * don't work for this use case as those would not be part of page rendering.
439     *
440     * @return string[]
441     */
442    public function getPeers() {
443        return $this->peers;
444    }
445
446    /**
447     * @return string[]
448     */
449    public function getMessages() {
450        return $this->messages;
451    }
452
453    /**
454     * Get user rights required to enable this gadget
455     * @return string[]
456     */
457    public function getRequiredRights() {
458        return $this->requiredRights;
459    }
460
461    /**
462     * Get page actions on which the gadget loads
463     * @return string[]
464     */
465    public function getRequiredActions() {
466        return $this->requiredActions;
467    }
468
469    /**
470     * Get page namespaces in which this gadget loads
471     *
472     * Use isNamespaceSupported() instead for basic checks, as
473     * namespace IDs may be returned as numerical strings.
474     *
475     * Unknown namespaces and non-numerical values result in warnings
476     * on Special:Gadgets, via GadgetRepo::checkInvalidLoadConditions.
477     *
478     * @return int[]|string[]
479     */
480    public function getRequiredNamespaces() {
481        return $this->requiredNamespaces;
482    }
483
484    /**
485     * Returns categories in which this gadget loads
486     * @return string[]
487     */
488    public function getRequiredCategories() {
489        return $this->requiredCategories;
490    }
491
492    /**
493     * Get skins in which this gadget loads
494     * @return string[]
495     */
496    public function getRequiredSkins() {
497        return $this->requiredSkins;
498    }
499
500    /**
501     * Get page content models for which this gadget loads
502     * @return string[]
503     */
504    public function getRequiredContentModels() {
505        return $this->requiredContentModels;
506    }
507
508    /**
509     * Returns the load type of this Gadget's ResourceLoader module
510     * @return string 'styles' or 'general'
511     */
512    public function getType() {
513        if ( $this->type === 'styles' || $this->type === 'general' ) {
514            return $this->type;
515        }
516        // Similar to ResourceLoaderWikiModule default
517        if ( $this->getStyles() && !$this->getScripts() && !$this->dependencies ) {
518            return 'styles';
519        }
520
521        return 'general';
522    }
523
524    /**
525     * Get validation warnings
526     * @return string[]
527     */
528    public function getValidationWarnings(): array {
529        $warnings = [];
530
531        // Default gadget requiring ES6
532        if ( $this->onByDefault && $this->requiresES6 ) {
533            $warnings[] = "gadgets-validate-es6default";
534        }
535
536        // Gadget containing files with uncrecognised suffixes
537        if ( array_diff( $this->pages, $this->getScriptsAndStyles() ) ) {
538            $warnings[] = "gadgets-validate-unknownpages";
539        }
540
541        // Non-package gadget containing JSON files
542        if ( !$this->package && $this->getJSONs() ) {
543            $warnings[] = "gadgets-validate-json";
544        }
545
546        // Package gadget without a script file in it (to serve as entry point)
547        if ( $this->package && !$this->getScripts() ) {
548            $warnings[] = "gadgets-validate-noentrypoint";
549        }
550
551        // Gadget with type=styles having non-CSS files
552        if ( $this->type === 'styles' && $this->getScripts() ) {
553            $warnings[] = "gadgets-validate-scriptsnotallowed";
554        }
555
556        // Style-only gadgets having peers
557        if ( $this->getType() === 'styles' && $this->peers ) {
558            $warnings[] = "gadgets-validate-stylepeers";
559        }
560
561        return $warnings;
562    }
563}