Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
124 / 129
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiGadgetsDefinitionRepo
96.12% covered (success)
96.12%
124 / 129
70.00% covered (warning)
70.00%
7 / 10
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGadget
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getGadgetIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handlePageUpdate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 purgeDefinitionCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeDefinitionCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 loadGadgets
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 fetchStructuredList
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 listFromDefinition
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 newFromDefinition
95.65% covered (success)
95.65%
66 / 69
0.00% covered (danger)
0.00%
0 / 1
20
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\Content\TextContent;
25use MediaWiki\Linker\LinkTarget;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Revision\RevisionLookup;
28use MediaWiki\Revision\SlotRecord;
29use MediaWiki\Title\Title;
30use Wikimedia\ObjectCache\BagOStuff;
31use Wikimedia\ObjectCache\WANObjectCache;
32
33/**
34 * Gadgets repo powered by MediaWiki:Gadgets-definition
35 */
36class MediaWikiGadgetsDefinitionRepo extends GadgetRepo {
37    private const CACHE_VERSION = 6;
38
39    /** @var array<string,array>|null */
40    private ?array $definitions = null;
41
42    public function __construct(
43        private readonly WANObjectCache $wanCache,
44        private readonly RevisionLookup $revLookup,
45        private readonly BagOStuff $srvCache,
46    ) {
47    }
48
49    /**
50     * @param string $id
51     * @throws InvalidArgumentException
52     * @return Gadget
53     */
54    public function getGadget( string $id ): Gadget {
55        $gadgets = $this->loadGadgets();
56        if ( !isset( $gadgets[$id] ) ) {
57            throw new InvalidArgumentException( "No gadget registered for '$id'" );
58        }
59
60        return new Gadget( $gadgets[$id] );
61    }
62
63    public function getGadgetIds(): array {
64        return array_keys( $this->loadGadgets() );
65    }
66
67    public function handlePageUpdate( LinkTarget $target ): void {
68        if ( $target->inNamespace( NS_MEDIAWIKI ) && $target->getDBkey() === 'Gadgets-definition' ) {
69            $this->purgeDefinitionCache();
70        }
71    }
72
73    /**
74     * Purge the definitions cache, for example when MediaWiki:Gadgets-definition is edited.
75     */
76    private function purgeDefinitionCache(): void {
77        $key = $this->makeDefinitionCacheKey( $this->wanCache );
78
79        $this->wanCache->delete( $key );
80        $this->srvCache->delete( $key );
81        $this->definitions = null;
82    }
83
84    private function makeDefinitionCacheKey( WANObjectCache $cache ): string {
85        return $cache->makeKey(
86            'gadgets-definition',
87            Gadget::GADGET_CLASS_VERSION,
88            self::CACHE_VERSION
89        );
90    }
91
92    /**
93     * Get list of gadgets.
94     *
95     * @return array<string,array> List of Gadget objects
96     */
97    protected function loadGadgets(): array {
98        if ( defined( 'MW_PHPUNIT_TEST' ) && MediaWikiServices::getInstance()->isStorageDisabled() ) {
99            // Bail out immediately if storage is disabled. This should never happen in normal operations, but can
100            // happen a lot in tests: this method is called from the UserGetDefaultOptions hook handler, so any test
101            // that uses UserOptionsLookup will end up reaching this code, which is problematic if the test is not
102            // in the Database group (T155147).
103            return [];
104        }
105        // From back to front:
106        //
107        // 3. wan cache (e.g. memcached)
108        //    This improves end-user latency and reduces database load.
109        //    It is purged when the data changes.
110        //
111        // 2. server cache (e.g. APCu).
112        //    Very short blind TTL, mainly to avoid high memcached I/O.
113        //
114        // 1. process cache. Faster repeat calls.
115        if ( $this->definitions === null ) {
116            $key = $this->makeDefinitionCacheKey( $this->wanCache );
117            $this->definitions = $this->srvCache->getWithSetCallback(
118                $key,
119                // between 7 and 15 seconds to avoid memcached/lockTSE stampede (T203786)
120                mt_rand( 7, 15 ),
121                function () use ( $key ) {
122                    return $this->wanCache->getWithSetCallback(
123                        $key,
124                        // 1 day
125                        Gadget::CACHE_TTL,
126                        $this->fetchStructuredList( ... ),
127                        [
128                            'version' => 2,
129                            // Avoid database stampede
130                            'lockTSE' => 300,
131                         ]
132                    );
133                }
134            );
135        }
136        return $this->definitions;
137    }
138
139    /**
140     * Fetch list of gadgets and returns it as associative array of sections with gadgets
141     * e.g. [ $name => $gadget1, etc. ]
142     * @return array<string,array>
143     */
144    public function fetchStructuredList() {
145        // T157210: avoid using wfMessage() to avoid staleness due to cache layering
146        $title = Title::makeTitle( NS_MEDIAWIKI, 'Gadgets-definition' );
147        $revRecord = $this->revLookup->getRevisionByTitle( $title );
148        if ( !$revRecord
149            || !$revRecord->getContent( SlotRecord::MAIN )
150            || $revRecord->getContent( SlotRecord::MAIN )->isEmpty()
151        ) {
152            return [];
153        }
154
155        $content = $revRecord->getContent( SlotRecord::MAIN );
156        $g = ( $content instanceof TextContent ) ? $content->getText() : '';
157
158        $gadgets = $this->listFromDefinition( $g );
159
160        wfDebug( __METHOD__ . ": MediaWiki:Gadgets-definition parsed, cache entry should be updated\n" );
161
162        return $gadgets;
163    }
164
165    /**
166     * Generates a structured list of Gadget objects from a definition
167     *
168     * @param string $definition
169     * @return array<string,array> List of Gadget objects indexed by the gadget's name.
170     */
171    private function listFromDefinition( $definition ): array {
172        $definition = preg_replace( '/<!--.*?-->/s', '', $definition );
173        $lines = preg_split( '/(\r\n|\r|\n)+/', $definition );
174
175        $gadgets = [];
176        $section = '';
177
178        foreach ( $lines as $line ) {
179            $m = [];
180            if ( preg_match( '/^==+ *([^*:\s|]+)\s*(?<!=)==+\s*$/', $line, $m ) ) {
181                $section = $m[1];
182            } else {
183                $gadget = $this->newFromDefinition( $line, $section );
184                if ( $gadget ) {
185                    $gadgets[$gadget->getName()] = $gadget->toArray();
186                }
187            }
188        }
189
190        return $gadgets;
191    }
192
193    /**
194     * Creates an instance of this class from definition in MediaWiki:Gadgets-definition
195     * @param string $definition Gadget definition
196     * @param string $section
197     * @return Gadget|false Instance of Gadget class or false if $definition is invalid
198     */
199    public function newFromDefinition( $definition, $section ) {
200        if ( !preg_match(
201            '/^\*+ *([a-zA-Z](?:[-_:.\w ]*[a-zA-Z0-9])?)(\s*\[.*?\])?\s*((\|[^|]*)+)\s*$/',
202            $definition,
203            $matches
204        ) ) {
205            return false;
206        }
207        [ , $name, $options, $pages ] = $matches;
208        $options = trim( $options, ' []' );
209
210        // NOTE: the gadget name is used as part of the name of a form field,
211        // and must follow the rules defined in https://www.w3.org/TR/html4/types.html#type-cdata
212        // Also, title-normalization applies.
213        $name = str_replace( ' ', '_', $name );
214        // If the name is too long, then RL will throw an exception when
215        // we try to register the module
216        if ( !Gadget::isValidGadgetID( $name ) ) {
217            return false;
218        }
219
220        $info = [
221            'section' => $section,
222            'name' => $name,
223            'definition' => $definition,
224        ];
225
226        foreach ( preg_split( '/\s*\|+\s*/', $options, -1, PREG_SPLIT_NO_EMPTY ) as $option ) {
227            $arr = preg_split( '/\s*=\s*/', $option, 2 );
228            $option = $arr[0];
229            if ( isset( $arr[1] ) ) {
230                $params = explode( ',', $arr[1] );
231                $params = array_map( 'trim', $params );
232            } else {
233                $params = [];
234            }
235
236            switch ( $option ) {
237                case 'dependencies':
238                    $info['dependencies'] = $params;
239                    break;
240                case 'peers':
241                    $info['peers'] = $params;
242                    break;
243                case 'codexIcons':
244                    $info['codexIcons'] = $params;
245                    break;
246                case 'rights':
247                    $info['requiredRights'] = $params;
248                    break;
249                case 'hidden':
250                    $info['hidden'] = true;
251                    break;
252                case 'actions':
253                    $info['requiredActions'] = $params;
254                    break;
255                case 'skins':
256                    $info['requiredSkins'] = $params;
257                    break;
258                case 'namespaces':
259                    $info['requiredNamespaces'] = $params;
260                    break;
261                case 'categories':
262                    $info['requiredCategories'] = $params;
263                    break;
264                case 'contentModels':
265                    $info['requiredContentModels'] = $params;
266                    break;
267                case 'default':
268                    $info['onByDefault'] = true;
269                    break;
270                case 'package':
271                    $info['package'] = true;
272                    break;
273                case 'type':
274                    // Single value, not a list
275                    $info['type'] = $params[0] ?? '';
276                    break;
277                case 'supportsUrlLoad':
278                    $val = $params[0] ?? '';
279                    $info['supportsUrlLoad'] = $val !== 'false';
280                    break;
281            }
282        }
283
284        foreach ( preg_split( '/\s*\|+\s*/', $pages, -1, PREG_SPLIT_NO_EMPTY ) as $page ) {
285            $info['pages'][] = self::RESOURCE_TITLE_PREFIX . trim( $page );
286        }
287
288        return new Gadget( $info );
289    }
290}