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