Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
67.04% |
120 / 179 |
|
22.22% |
2 / 9 |
CRAP | |
0.00% |
0 / 1 |
Hooks | |
67.04% |
120 / 179 |
|
22.22% |
2 / 9 |
148.83 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getUserCounts | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
onSaveUserOptions | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
56 | |||
onGetPreferences | |
80.51% |
95 / 118 |
|
0.00% |
0 / 1 |
41.06 | |||
onPreferencesGetIcon | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onUserGetDefaultOptions | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
onMakeGlobalVariablesScript | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
onSkinTemplateNavigation__Universal | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
onExtensionTypes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This file is part of the MediaWiki extension BetaFeatures. |
4 | * |
5 | * BetaFeatures is free software: you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation, either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * BetaFeatures is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License |
16 | * along with BetaFeatures. If not, see <http://www.gnu.org/licenses/>. |
17 | * |
18 | * BetaFeatures extension hooks |
19 | * |
20 | * @file |
21 | * @ingroup Extensions |
22 | * @copyright 2013 Mark Holmquist and others; see AUTHORS |
23 | * @license GPL-2.0-or-later |
24 | */ |
25 | |
26 | // phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName |
27 | |
28 | namespace MediaWiki\Extension\BetaFeatures; |
29 | |
30 | use Exception; |
31 | use MediaWiki\Config\Config; |
32 | use MediaWiki\Context\RequestContext; |
33 | use MediaWiki\Deferred\DeferredUpdates; |
34 | use MediaWiki\Extension\BetaFeatures\Hooks\HookRunner; |
35 | use MediaWiki\Hook\ExtensionTypesHook; |
36 | use MediaWiki\Hook\PreferencesGetIconHook; |
37 | use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook; |
38 | use MediaWiki\HookContainer\HookContainer; |
39 | use MediaWiki\JobQueue\JobQueueGroupFactory; |
40 | use MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook; |
41 | use MediaWiki\Output\OutputPage; |
42 | use MediaWiki\Preferences\Hook\GetPreferencesHook; |
43 | use MediaWiki\SpecialPage\SpecialPage; |
44 | use MediaWiki\User\Hook\UserGetDefaultOptionsHook; |
45 | use MediaWiki\User\Options\Hook\SaveUserOptionsHook; |
46 | use MediaWiki\User\Options\UserOptionsManager; |
47 | use MediaWiki\User\User; |
48 | use MediaWiki\User\UserFactory; |
49 | use MediaWiki\User\UserIdentity; |
50 | use MediaWiki\User\UserIdentityUtils; |
51 | use ObjectCacheFactory; |
52 | use SkinFactory; |
53 | use SkinTemplate; |
54 | use Wikimedia\Rdbms\IConnectionProvider; |
55 | |
56 | class Hooks implements |
57 | ExtensionTypesHook, |
58 | GetPreferencesHook, |
59 | MakeGlobalVariablesScriptHook, |
60 | PreferencesGetIconHook, |
61 | SaveUserOptionsHook, |
62 | SkinTemplateNavigation__UniversalHook, |
63 | UserGetDefaultOptionsHook |
64 | { |
65 | |
66 | /** |
67 | * @var array An array of each of the available Beta Features, with their requirements, if any. |
68 | * It is passed client-side for JavaScript rendering/responsiveness. |
69 | */ |
70 | private static $features = []; |
71 | |
72 | private Config $config; |
73 | private IConnectionProvider $dbProvider; |
74 | private HookContainer $hookContainer; |
75 | private JobQueueGroupFactory $jobQueueGroupFactory; |
76 | private SkinFactory $skinFactory; |
77 | private UserFactory $userFactory; |
78 | private UserIdentityUtils $userIdentityUtils; |
79 | private UserOptionsManager $userOptionsManager; |
80 | private ObjectCacheFactory $objectCacheFactory; |
81 | |
82 | public function __construct( |
83 | Config $config, |
84 | IConnectionProvider $dbProvider, |
85 | HookContainer $hookContainer, |
86 | JobQueueGroupFactory $jobQueueGroupFactory, |
87 | SkinFactory $skinFactory, |
88 | UserFactory $userFactory, |
89 | UserIdentityUtils $userIdentityUtils, |
90 | UserOptionsManager $userOptionsManager, |
91 | ObjectCacheFactory $objectCacheFactory |
92 | ) { |
93 | $this->config = $config; |
94 | $this->dbProvider = $dbProvider; |
95 | $this->hookContainer = $hookContainer; |
96 | $this->jobQueueGroupFactory = $jobQueueGroupFactory; |
97 | $this->skinFactory = $skinFactory; |
98 | $this->userFactory = $userFactory; |
99 | $this->userIdentityUtils = $userIdentityUtils; |
100 | $this->userOptionsManager = $userOptionsManager; |
101 | $this->objectCacheFactory = $objectCacheFactory; |
102 | } |
103 | |
104 | /** |
105 | * @param string[] $prefs |
106 | * @param IConnectionProvider $dbProvider |
107 | * @return int[] |
108 | */ |
109 | public static function getUserCounts( array $prefs, IConnectionProvider $dbProvider ) { |
110 | $counts = []; |
111 | if ( !$prefs ) { |
112 | return $counts; |
113 | } |
114 | |
115 | $dbr = $dbProvider->getReplicaDatabase(); |
116 | $res = $dbr->newSelectQueryBuilder() |
117 | ->select( [ 'feature', 'number' ] ) |
118 | ->from( 'betafeatures_user_counts' ) |
119 | ->where( [ 'feature' => $prefs ] ) |
120 | ->caller( __METHOD__ )->fetchResultSet(); |
121 | |
122 | foreach ( $res as $row ) { |
123 | $counts[$row->feature] = $row->number; |
124 | } |
125 | |
126 | return $counts; |
127 | } |
128 | |
129 | /** |
130 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions |
131 | * |
132 | * @param UserIdentity $user User who's just saved their preferences |
133 | * @param array &$modifiedOptions List of modified options |
134 | * @param array $originalOptions List of original user options |
135 | * @throws Exception |
136 | */ |
137 | public function onSaveUserOptions( |
138 | UserIdentity $user, |
139 | array &$modifiedOptions, |
140 | array $originalOptions |
141 | ) { |
142 | if ( !$user->isRegistered() || $this->userIdentityUtils->isTemp( $user ) ) { |
143 | // Anonymous and temporary users do not have options, shorten out. |
144 | return; |
145 | } |
146 | |
147 | $betaFeatures = $this->config->get( 'BetaFeatures' ); |
148 | $user = $this->userFactory->newFromUserIdentity( $user ); |
149 | ( new HookRunner( $this->hookContainer ) )->onGetBetaFeaturePreferences( $user, $betaFeatures ); |
150 | |
151 | $jobs = []; |
152 | foreach ( $betaFeatures as $name => $option ) { |
153 | if ( !array_key_exists( $name, $modifiedOptions ) ) { |
154 | continue; |
155 | } |
156 | $newVal = $modifiedOptions[$name]; |
157 | $oldVal = $originalOptions[$name] ?? null; |
158 | // Check if this preference meaningfully changed |
159 | if ( $oldVal == $newVal ) { |
160 | // unchanged |
161 | continue; |
162 | } |
163 | // Enqueue a job to update the count for this preference |
164 | $jobs[] = new UpdateBetaFeatureUserCountsJob( |
165 | [ 'prefs' => [ $name ] ], |
166 | $this->dbProvider |
167 | ); |
168 | } |
169 | if ( $jobs !== [] ) { |
170 | $this->jobQueueGroupFactory->makeJobQueueGroup()->push( $jobs ); |
171 | } |
172 | } |
173 | |
174 | /** |
175 | * @param User $user |
176 | * @param array[] &$prefs |
177 | * @throws BetaFeaturesMissingFieldException |
178 | */ |
179 | public function onGetPreferences( $user, &$prefs ) { |
180 | $betaPrefs = $this->config->get( 'BetaFeatures' ); |
181 | $depHooks = []; |
182 | |
183 | $hookRunner = new HookRunner( $this->hookContainer ); |
184 | $hookRunner->onGetBetaFeaturePreferences( $user, $betaPrefs ); |
185 | |
186 | // The following messages are generated upstream by the 'section' value |
187 | // * prefs-betafeatures |
188 | // * prefs-description-betafeatures |
189 | $count = count( $betaPrefs ); |
190 | $prefs['betafeatures-section-desc'] = [ |
191 | 'type' => 'info', |
192 | 'default' => static function () use ( $count ) { |
193 | return wfMessage( 'betafeatures-section-desc' ) |
194 | ->numParams( $count ) |
195 | ->parseAsBlock(); |
196 | }, |
197 | 'section' => 'betafeatures', |
198 | 'raw' => true, |
199 | ]; |
200 | |
201 | $prefs['betafeatures-auto-enroll'] = [ |
202 | 'type' => 'check', |
203 | 'label-message' => 'betafeatures-auto-enroll', |
204 | 'help-message' => 'betafeatures-auto-enroll-help', |
205 | 'section' => 'betafeatures', |
206 | ]; |
207 | |
208 | // Purely visual field. |
209 | $prefs['betafeatures-breaking-hr'] = [ |
210 | 'class' => HTMLHorizontalRuleField::class, |
211 | 'section' => 'betafeatures', |
212 | ]; |
213 | |
214 | $counts = self::getUserCounts( array_keys( $betaPrefs ), $this->dbProvider ); |
215 | |
216 | // Set up dependency hooks array |
217 | // This complex structure brought to you by Per-Wiki Configuration, |
218 | // coming soon to a wiki very near you. |
219 | $hookRunner->onGetBetaFeatureDependencyHooks( $depHooks ); |
220 | |
221 | $autoEnrollSaveSettings = []; |
222 | $autoEnrollAll = $this->userOptionsManager->getBoolOption( $user, 'betafeatures-auto-enroll' ); |
223 | |
224 | $autoEnroll = []; |
225 | |
226 | foreach ( $betaPrefs as $key => $info ) { |
227 | if ( isset( $info['auto-enrollment'] ) ) { |
228 | $autoEnroll[$info['auto-enrollment']] = $key; |
229 | } |
230 | } |
231 | |
232 | $hiddenPrefs = $this->config->get( 'HiddenPrefs' ); |
233 | $allowlist = $this->config->get( 'BetaFeaturesAllowList' ); |
234 | |
235 | foreach ( $betaPrefs as $key => $info ) { |
236 | // Check if feature should be skipped |
237 | if ( |
238 | // Check if feature is hidden |
239 | in_array( $key, $hiddenPrefs ) || |
240 | // Check if feature is in the allow list |
241 | ( is_array( $allowlist ) && !in_array( $key, $allowlist ) ) || |
242 | // Check if dependencies are set but not met |
243 | ( |
244 | isset( $info['dependent'] ) && |
245 | $info['dependent'] === true && |
246 | isset( $depHooks[$key] ) && |
247 | !$this->hookContainer->run( $depHooks[$key] ) |
248 | ) |
249 | ) { |
250 | continue; |
251 | } |
252 | |
253 | $opt = [ |
254 | 'class' => HTMLFeatureField::class, |
255 | 'section' => 'betafeatures', |
256 | 'disable-if' => [ '===', 'betafeatures-auto-enroll', '1' ], |
257 | ]; |
258 | |
259 | $requiredFields = [ |
260 | 'label-message' => true, |
261 | 'desc-message' => true, |
262 | 'screenshot' => false, |
263 | 'requirements' => false, |
264 | 'info-link' => false, |
265 | 'info-message' => false, |
266 | 'discussion-link' => false, |
267 | 'discussion-message' => false, |
268 | 'disabled' => false, |
269 | ]; |
270 | |
271 | foreach ( $requiredFields as $field => $required ) { |
272 | if ( isset( $info[$field] ) ) { |
273 | $opt[$field] = $info[$field]; |
274 | } elseif ( $required ) { |
275 | // A required field isn't present in the info array |
276 | // we got from the GetBetaFeaturePreferences hook. |
277 | // Don't add this feature to the form. |
278 | throw new BetaFeaturesMissingFieldException( |
279 | "The field {$field} was missing from the beta feature {$key}." |
280 | ); |
281 | } |
282 | } |
283 | |
284 | if ( isset( $counts[$key] ) ) { |
285 | $opt['user-count'] = $counts[$key]; |
286 | } |
287 | |
288 | // Set the beta feature in the standard preferences array |
289 | // Just before, unset the key to resort it in the array, in the case the key was already set |
290 | unset( $prefs[$key] ); |
291 | $prefs[$key] = $opt; |
292 | |
293 | $autoEnrollForThisPref = false; |
294 | |
295 | if ( isset( $info['group'] ) && isset( $autoEnroll[$info['group']] ) ) { |
296 | $autoEnrollForThisPref = $this->userOptionsManager |
297 | ->getBoolOption( $user, $autoEnroll[$info['group']] ); |
298 | } |
299 | |
300 | $exemptAutoEnroll = ( $info['exempt-from-auto-enrollment'] ?? false ) |
301 | || ( $info['disabled'] ?? false ); |
302 | $autoEnrollHere = !$exemptAutoEnroll && ( $autoEnrollAll || $autoEnrollForThisPref ); |
303 | |
304 | // Use raw value for existence test |
305 | $currentValue = $this->userOptionsManager->getOption( $user, $key ); |
306 | |
307 | // Keep it break now... The tests applied are against the comments below. |
308 | // Fixing all the tests is not worthwhile, the auto-enroll logic should be refactored later. |
309 | if ( $autoEnrollHere && $currentValue !== '1' ) { |
310 | // We haven't seen this before, and the user has auto-enroll enabled! |
311 | // Set the option to true and make it visible for the current user object |
312 | $this->userOptionsManager->setOption( $user, $key, true ); |
313 | // Also put it aside for saving the settings later |
314 | $autoEnrollSaveSettings[$key] = true; |
315 | } |
316 | |
317 | self::$features[$key] = []; |
318 | self::$features[$key]['__skip-auto-enroll'] = $exemptAutoEnroll; |
319 | } |
320 | |
321 | foreach ( $betaPrefs as $key => $info ) { |
322 | if ( isset( $prefs[$key]['requirements'] ) ) { |
323 | // Check which other beta features are required, and fetch their labels |
324 | if ( isset( $prefs[$key]['requirements']['betafeatures'] ) ) { |
325 | $requiredPrefs = []; |
326 | foreach ( $prefs[$key]['requirements']['betafeatures'] as $preference ) { |
327 | if ( !$this->userOptionsManager->getBoolOption( $user, $preference ) ) { |
328 | $requiredPrefs[] = $prefs[$preference]['label-message']; |
329 | } |
330 | } |
331 | if ( count( $requiredPrefs ) ) { |
332 | $prefs[$key]['requirements']['betafeatures-messages'] = $requiredPrefs; |
333 | } |
334 | } |
335 | |
336 | // Test skin support |
337 | if ( isset( $prefs[$key]['requirements']['skins'] ) ) { |
338 | // Remove any skins that aren't installed or users can't choose |
339 | $prefs[$key]['requirements']['skins'] = array_intersect( |
340 | /** @phan-suppress-next-line PhanTypeInvalidDimOffset,PhanTypeMismatchArgumentInternal */ |
341 | $prefs[$key]['requirements']['skins'], |
342 | array_keys( $this->skinFactory->getAllowedSkins() ) |
343 | ); |
344 | |
345 | if ( empty( $prefs[$key]['requirements']['skins'] ) ) { |
346 | // If there are no valid skins, don't show the preference |
347 | wfDebugLog( 'BetaFeatures', "The $key BetaFeature has no valid skins installed." ); |
348 | continue; |
349 | } |
350 | // Also check if the user's current skin is supported |
351 | $prefs[$key]['requirements']['skin-not-supported'] = !in_array( |
352 | RequestContext::getMain()->getSkin()->getSkinName(), |
353 | $prefs[$key]['requirements']['skins'] |
354 | ); |
355 | } |
356 | } |
357 | |
358 | // If a unsupported browsers list is supplied, store so it can be passed as JSON |
359 | self::$features[$key]['unsupportedList'] = $prefs[$key]['requirements']['unsupportedList'] ?? null; |
360 | } |
361 | |
362 | if ( $autoEnrollSaveSettings !== [] ) { |
363 | // Save the preferences to the DB post-send |
364 | DeferredUpdates::addCallableUpdate( |
365 | function () use ( $user, $autoEnrollSaveSettings ) { |
366 | $cache = $this->objectCacheFactory->getLocalClusterInstance(); |
367 | $key = $cache->makeKey( __CLASS__, 'prefs-update', $user->getId() ); |
368 | // T95839: If concurrent requests pile on (e.g. multiple tabs), only let one |
369 | // thread bother doing these updates. This avoids pointless error log spam. |
370 | if ( $cache->lock( $key, 0, $cache::TTL_MINUTE ) ) { |
371 | // Refresh, because the settings could be changed in the meantime by api or special page |
372 | $userLatest = $user->getInstanceForUpdate(); |
373 | // Apply the settings and save |
374 | foreach ( $autoEnrollSaveSettings as $key => $option ) { |
375 | $this->userOptionsManager->setOption( $userLatest, $key, $option ); |
376 | } |
377 | $this->userOptionsManager->saveOptions( $userLatest ); |
378 | $cache->unlock( $key ); |
379 | } |
380 | } |
381 | ); |
382 | } |
383 | } |
384 | |
385 | /** |
386 | * Add icon for Special:Preferences mobile layout |
387 | * |
388 | * @param array &$iconNames Array of icon names for their respective sections. |
389 | */ |
390 | public function onPreferencesGetIcon( &$iconNames ) { |
391 | $iconNames[ 'betafeatures' ] = 'labFlask'; |
392 | } |
393 | |
394 | /** |
395 | * Add default preferences values |
396 | * |
397 | * @param array &$defaultOptions Array of preference keys and their default values. |
398 | */ |
399 | public function onUserGetDefaultOptions( &$defaultOptions ) { |
400 | $betaPrefs = $this->config->get( 'BetaFeatures' ); |
401 | |
402 | foreach ( $betaPrefs as $key => $_ ) { |
403 | $defaultOptions[$key] = false; |
404 | } |
405 | } |
406 | |
407 | /** |
408 | * @param array &$vars |
409 | * @param OutputPage $out |
410 | */ |
411 | public function onMakeGlobalVariablesScript( &$vars, $out ): void { |
412 | if ( self::$features ) { |
413 | // This is added to page view HTML on all articles. |
414 | // FIXME: Move this to the preferences page somehow, or |
415 | // bundle with the module that loads betafeatures.js. |
416 | $vars['wgBetaFeaturesFeatures'] = self::$features; |
417 | } |
418 | } |
419 | |
420 | /** |
421 | * @param SkinTemplate $skintemplate |
422 | * @param array[] &$links |
423 | */ |
424 | public function onSkinTemplateNavigation__Universal( |
425 | $skintemplate, |
426 | &$links |
427 | ): void { |
428 | $user = $skintemplate->getUser(); |
429 | if ( $user->isNamed() ) { |
430 | $personalUrls = $links['user-menu'] ?? []; |
431 | $personalUrls = wfArrayInsertAfter( $personalUrls, [ |
432 | // The following messages are generated upstream |
433 | // * tooltip-pt-betafeatures |
434 | 'betafeatures' => [ |
435 | 'text' => wfMessage( 'betafeatures-toplink' )->text(), |
436 | 'href' => SpecialPage::getTitleFor( |
437 | 'Preferences', false, 'mw-prefsection-betafeatures' |
438 | )->getLinkURL(), |
439 | 'active' => $skintemplate->getTitle()->isSpecial( 'Preferences' ), |
440 | 'icon' => 'labFlask' |
441 | ], |
442 | ], 'preferences' ); |
443 | $links['user-menu'] = $personalUrls; |
444 | } |
445 | } |
446 | |
447 | /** |
448 | * @param string[] &$extTypes |
449 | */ |
450 | public function onExtensionTypes( &$extTypes ) { |
451 | $extTypes['betafeatures'] = wfMessage( 'betafeatures-extension-type' )->text(); |
452 | } |
453 | |
454 | } |