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