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