Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.01% covered (warning)
63.01%
138 / 219
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalPreferencesFactory
63.01% covered (warning)
63.01%
138 / 219
36.84% covered (danger)
36.84%
7 / 19
422.21
0.00% covered (danger)
0.00%
0 / 1
 setAutoGlobals
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormDescriptor
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
6.60
 getPreferencesLocal
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
6.01
 getPreferencesGlobal
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
90
 saveFormData
88.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
15.34
 findCheckMatrices
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 getSectionFragmentId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isGlobalizablePreference
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
11.04
 isGlobalPrefName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLocalPrefName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isUserGlobalized
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getUserID
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getGlobalPreferencesValues
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
2.50
 setGlobalPreferences
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 resetGlobalUserSettings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onGlobalPrefsPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 onLocalPrefsPage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 handleLocalPreferencesChange
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 makeStorage
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 * Implements global preferences for MediaWiki
4 *
5 * @author Kunal Mehta <legoktm@gmail.com>
6 * @license http://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
7 * @file
8 * @ingroup Extensions
9 *
10 * Partially based off of work by Werdna
11 * https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790
12 */
13
14namespace GlobalPreferences;
15
16use IContextSource;
17use LogicException;
18use MediaWiki\Html\Html;
19use MediaWiki\Language\RawMessage;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Preferences\DefaultPreferencesFactory;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Status\Status;
24use MediaWiki\User\CentralId\CentralIdLookup;
25use MediaWiki\User\User;
26use MediaWiki\User\UserIdentity;
27use OOUI\ButtonWidget;
28use RequestContext;
29use RuntimeException;
30
31/**
32 * Global preferences.
33 * @package GlobalPreferences
34 */
35class GlobalPreferencesFactory extends DefaultPreferencesFactory {
36
37    /**
38     * The suffix appended to preference names
39     * for the associated preference that tracks whether they have a local override.
40     */
41    public const LOCAL_EXCEPTION_SUFFIX = '-local-exception';
42
43    /**
44     * The suffix appended to preference names for their global counterparts.
45     */
46    public const GLOBAL_EXCEPTION_SUFFIX = '-global';
47
48    /**
49     * @var string[] Names of autoglobal options
50     */
51    protected $autoGlobals = [];
52
53    /**
54     * "bad" preferences that we should remove from
55     * Special:GlobalPrefs
56     * @var array
57     */
58    protected $disallowedPreferences = [
59        // Stored in user table, doesn't work yet
60        'realname',
61        // @todo Show CA user id / shared user table id?
62        'userid',
63        // @todo Show CA global groups instead?
64        'usergroups',
65        // @todo Should global edit count instead?
66        'editcount',
67        'registrationdate',
68        // Signature could be global, but links in it are too likely to break.
69        'nickname',
70        'fancysig',
71    ];
72
73    /**
74     * Preference types that we should not add a checkbox for
75     * @var array
76     */
77    protected $typesPrevented = [
78        'info',
79        'hidden',
80        'api',
81    ];
82
83    /**
84     * Preference classes that are allowed to be global
85     * @var array
86     */
87    protected $allowedClasses = [
88        \HTMLSelectOrOtherField::class,
89        \MediaWiki\Extension\BetaFeatures\HTMLFeatureField::class,
90        \HTMLCheckMatrix::class,
91    ];
92
93    /**
94     * Sets the list of options for which setting the local value should transparently update
95     * the global value.
96     *
97     * @param string[] $list
98     */
99    public function setAutoGlobals( array $list ) {
100        $this->autoGlobals = $list;
101    }
102
103    /**
104     * Get all user preferences.
105     * @param User $user
106     * @param IContextSource $context The current request context
107     * @return array|null
108     */
109    public function getFormDescriptor( User $user, IContextSource $context ) {
110        $globalPrefs = $this->getGlobalPreferencesValues( $user, Storage::SKIP_CACHE );
111        // The above function can return false
112        $globalPrefNames = $globalPrefs ? array_keys( $globalPrefs ) : [];
113        $preferences = parent::getFormDescriptor( $user, $context );
114        if ( $this->onGlobalPrefsPage( $context ) ) {
115            if ( $globalPrefs === false ) {
116                throw new RuntimeException(
117                    "Attempted to load global preferences page for {$user->getName()} whose "
118                    . 'preference values failed to load'
119                );
120            }
121            return $this->getPreferencesGlobal( $user, $preferences, $globalPrefs, $context );
122        }
123        return $this->getPreferencesLocal( $user, $preferences, $globalPrefNames, $context );
124    }
125
126    /**
127     * Add help-text to the local preferences where they're globalized,
128     * and add the link to Special:GlobalPreferences to the personal preferences tab.
129     * @param User $user
130     * @param mixed[][] $preferences The preferences array.
131     * @param string[] $globalPrefNames The names of those preferences that are already global.
132     * @param IContextSource $context The current request context
133     * @return mixed[][]
134     */
135    protected function getPreferencesLocal(
136        User $user,
137        array $preferences,
138        array $globalPrefNames,
139        IContextSource $context
140    ) {
141        $this->logger->debug( "Creating local preferences array for '{$user->getName()}'" );
142        $modifiedPrefs = [];
143        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
144        foreach ( $preferences as $name => $def ) {
145            $modifiedPrefs[$name] = $def;
146            // If this is not globalizable, or hasn't been set globally.
147            if ( !isset( $def['section'] )
148                || !in_array( $name, $globalPrefNames )
149                || !$this->isGlobalizablePreference( $name, $def )
150            ) {
151                continue;
152            }
153            $localExName = $name . static::LOCAL_EXCEPTION_SUFFIX;
154            $localExValueUser = $userOptionsLookup->getBoolOption( $user, $localExName );
155
156            // Add a new local exception preference after this one.
157            $cssClasses = [
158                'mw-globalprefs-local-exception',
159                'mw-globalprefs-local-exception-for-' . $name,
160                'mw-prefs-search-noindex',
161            ];
162            $section = $def['section'];
163            $secFragment = static::getSectionFragmentId( $section );
164            $labelMsg = $context->msg( 'globalprefs-set-local-exception', [ $secFragment ] );
165            $modifiedPrefs[$localExName] = [
166                'type' => 'toggle',
167                'label-raw' => $labelMsg->parse(),
168                'default' => $localExValueUser,
169                'section' => $section,
170                'cssclass' => implode( ' ', $cssClasses ),
171                'hide-if' => $def['hide-if'] ?? false,
172                'disable-if' => $def['disable-if'] ?? false,
173            ];
174            if ( isset( $def['disable-if'] ) ) {
175                $modifiedPrefs[$name]['disable-if'] = [ 'OR', $def['disable-if'],
176                    [ '!==', $localExName, '1' ]
177                ];
178            } else {
179                $modifiedPrefs[$name]['disable-if'] = [ '!==', $localExName, '1' ];
180            }
181        }
182        $preferences = $modifiedPrefs;
183
184        // Add a link to GlobalPreferences to the local preferences form.
185        $linkObject = new ButtonWidget( [
186            'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )->getLinkURL(),
187            'label' => $context->msg( 'globalprefs-info-link' )->text(),
188        ] );
189        $link = $linkObject->toString();
190
191        $preferences['global-info'] = [
192            'type' => 'info',
193            'section' => 'personal/info',
194            'label-message' => 'globalprefs-info-label',
195            'raw' => true,
196            'default' => $link,
197            'help-message' => 'globalprefs-info-help',
198        ];
199
200        return $preferences;
201    }
202
203    /**
204     * Add the '-global' counterparts to all preferences, and override the local exception.
205     * @param User $user
206     * @param mixed[][] $preferences The preferences array.
207     * @param mixed[] $globalPrefs The array of global preferences.
208     * @param IContextSource $context The current request context
209     * @return mixed[][]
210     */
211    protected function getPreferencesGlobal(
212        User $user,
213        array $preferences,
214        array $globalPrefs,
215        IContextSource $context
216    ) {
217        $allPrefs = [];
218        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
219
220        // Add the "Restore all default preferences" link like on Special:Preferences
221        // (the normal preference entry is not globalizable)
222        $allPrefs['restoreprefs'] = [
223            'type' => 'info',
224            'raw' => true,
225            'label-message' => 'prefs-user-restoreprefs-label',
226            'default' => Html::element(
227                'a',
228                [
229                    'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )
230                        ->getSubpage( 'reset' )->getLocalURL()
231                ],
232                $context->msg( 'globalprefs-restoreprefs' )->text()
233            ),
234            'section' => 'personal/info',
235        ];
236
237        // Add all corresponding new global fields.
238        foreach ( $preferences as $pref => $def ) {
239            // Ignore unwanted preferences.
240            if ( !$this->isGlobalizablePreference( $pref, $def ) ) {
241                continue;
242            }
243            // Create the new preference.
244            $isGlobal = isset( $globalPrefs[$pref] );
245            $allPrefs[$pref . static::GLOBAL_EXCEPTION_SUFFIX] = [
246                'type' => 'toggle',
247                // Make the tooltip and the label the same, because the label is normally hidden.
248                'tooltip' => 'globalprefs-check-label',
249                'label-message' => 'tooltip-globalprefs-check-label',
250                'default' => $isGlobal,
251                'section' => $def['section'],
252                'cssclass' => 'mw-globalprefs-global-check mw-globalprefs-checkbox-for-' . $pref,
253                'hide-if' => $def['hide-if'] ?? false,
254                'disable-if' => $def['disable-if'] ?? false,
255            ];
256            if ( isset( $def['disable-if'] ) ) {
257                $def['disable-if'] = [ 'OR', $def['disable-if'],
258                    [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ]
259                ];
260            } else {
261                $def['disable-if'] = [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ];
262            }
263            // If this has a local exception, override it and append a help message to say so.
264            if ( $isGlobal && $userOptionsLookup->getBoolOption( $user, $pref . static::LOCAL_EXCEPTION_SUFFIX ) ) {
265                $def['default'] = $this->getOptionFromUser( $pref, $def, $globalPrefs );
266                // Create a link to the relevant section of GlobalPreferences.
267                $secFragment = static::getSectionFragmentId( $def['section'] );
268                $helpMsg = [ 'globalprefs-has-local-exception', $secFragment ];
269                // Merge the help messages.
270                if ( isset( $def['help'] ) ) {
271                    $def['help-messages'] = [ new RawMessage( $def['help'] . '<br />' ), $helpMsg ];
272                    unset( $def['help'] );
273                } elseif ( isset( $def['help-message'] ) ) {
274                    $def['help-messages'] = [ $def['help-message'], new RawMessage( '<br />' ), $helpMsg ];
275                    unset( $def['help-message'] );
276                } elseif ( isset( $def['help-messages'] ) ) {
277                    $def['help-messages'][] = new RawMessage( '<br />' );
278                    $def['help-messages'][] = $helpMsg;
279                } else {
280                    $def['help-message'] = $helpMsg;
281                }
282            }
283
284            $allPrefs[$pref] = $def;
285        }
286
287        return $allPrefs;
288    }
289
290    /**
291     * @inheritDoc
292     */
293    protected function saveFormData( $formData, \PreferencesFormOOUI $form, array $formDescriptor ) {
294        if ( !$this->onGlobalPrefsPage( $form ) ) {
295            return parent::saveFormData( $formData, $form, $formDescriptor );
296        }
297        '@phan-var GlobalPreferencesFormOOUI $form';
298
299        $user = $form->getModifiedUser();
300
301        // Difference from parent: removed 'editmyprivateinfo'
302        if ( !$this->permissionManager->userHasRight( $user, 'editmyoptions' ) ) {
303            return Status::newFatal( 'mypreferencesprotected' );
304        }
305
306        // Filter input
307        $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
308
309        // In the parent, we remove 'realname', but this is unnecessary
310        // here because GlobalPreferences removes this elsewhere, so
311        // the field will not even appear in this form
312
313        // Difference from parent: We are not collecting old user settings
314
315        foreach ( $this->getSaveBlacklist() as $b ) {
316            unset( $formData[$b] );
317        }
318
319        # If users have saved a value for a preference which has subsequently been disabled
320        # via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
321        # is subsequently re-enabled
322        $hiddenPrefs = $this->options->get( 'HiddenPrefs' );
323        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
324        foreach ( $hiddenPrefs as $pref ) {
325            # If the user has not set a non-default value here, the default will be returned
326            # and subsequently discarded
327            $formData[$pref] = $userOptionsLookup->getOption( $user, $pref, null, true );
328        }
329
330        // Difference from parent: We are ignoring RClimit preference; the parent
331        // checks for changes in that preference to update a hidden one, but the
332        // RCFilters product is okay with having that be localized
333
334        // We are also not resetting unused preferences in the global context.
335        // Otherwise, users could lose data by editing their global preferences
336        // on a wiki where some of the preferences don't exist. However, this means
337        // that preferences for undeployed extensions or removed code are never
338        // removed from the database...
339
340        // Setting the actual preference values:
341        $prefs = [];
342        $suffixLen = strlen( self::GLOBAL_EXCEPTION_SUFFIX );
343        foreach ( $formData as $name => $value ) {
344            // If this is the '-global' counterpart to a preference.
345            if ( self::isGlobalPrefName( $name ) && $value === true ) {
346                // Determine the real name of the preference.
347                $realName = substr( $name, 0, -$suffixLen );
348                if ( array_key_exists( $realName, $formData ) ) {
349                    $prefs[$realName] = $formData[$realName];
350                    if ( $prefs[$realName] === null ) {
351                        // Special case: null means don't save this row, which can keep the previous value
352                        $prefs[$realName] = '';
353                    }
354                }
355            }
356        }
357
358        $matricesToClear = [];
359        // Now special processing for CheckMatrices
360        foreach ( $this->findCheckMatrices( $formDescriptor ) as $name ) {
361            $globalName = $name . self::GLOBAL_EXCEPTION_SUFFIX;
362            // Find all separate controls for this CheckMatrix
363            $checkMatrix = preg_grep( '/^' . preg_quote( $name ) . '/', array_keys( $formData ) );
364            if ( array_key_exists( $globalName, $formData ) && $formData[$globalName] ) {
365                // Setting is global, copy the checkmatrices
366                foreach ( $checkMatrix as $input ) {
367                    $prefs[$input] = $formData[$input];
368                }
369                $prefs[$name] = true;
370            } else {
371                // Remove all the rows for this CheckMatrix
372                foreach ( $checkMatrix as $input ) {
373                    unset( $prefs[$input] );
374                }
375                $matricesToClear[] = $name;
376            }
377            unset( $prefs[$globalName] );
378        }
379        $this->setGlobalPreferences( $user, $prefs, $form->getContext(), $matricesToClear );
380
381        return true;
382    }
383
384    /**
385     * Finds CheckMatrix inputs in a form descriptor
386     *
387     * @param array $formDescriptor
388     * @return string[] Names of CheckMatrix options (parent only, not sub-checkboxes)
389     */
390    private function findCheckMatrices( array $formDescriptor ) {
391        $result = [];
392        foreach ( $formDescriptor as $name => $info ) {
393            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
394                 ( isset( $info['class'] ) && $info['class'] == \HTMLCheckMatrix::class )
395            ) {
396                $result[] = $name;
397            }
398        }
399
400        return $result;
401    }
402
403    /**
404     * Get the HTML fragment identifier for a given preferences section. This is the leading part
405     * of the provided section name, up to a slash (if there is one).
406     * @param string $section A section name, as used in a preference definition.
407     * @return string
408     */
409    public static function getSectionFragmentId( $section ) {
410        $sectionId = preg_replace( '#/.*$#', '', $section );
411        return 'mw-prefsection-' . $sectionId;
412    }
413
414    /**
415     * Checks whether the given preference is globalizable.
416     *
417     * @param string $name Preference name
418     * @param mixed[] &$info Preference description, by reference to avoid unnecessary cloning
419     * @return bool
420     */
421    protected function isGlobalizablePreference( $name, &$info ) {
422        // Preferences can opt out of being globalized by setting the 'noglobal' flag.
423        if ( isset( $info['noglobal'] ) && $info['noglobal'] === true ) {
424            return false;
425        }
426
427        // Ignore "is global" checkboxes
428        if ( static::isGlobalPrefName( $name ) ) {
429            return false;
430        }
431
432        // If a setting can't be changed, don't bother globalizing it
433        if ( isset( $info['disabled'] ) && $info['disabled'] ) {
434            return false;
435        }
436
437        // Allow explicitly define if a preference can be globalized,
438        // especially useful for custom field classes.
439        // TODO: Deprecate 'noglobal' in favour of this param.
440        if ( isset( $info['canglobal'] ) ) {
441            return (bool)$info['canglobal'];
442        }
443
444        $isAllowedType = isset( $info['type'] )
445            && !in_array( $info['type'], $this->typesPrevented )
446            && !in_array( $name, $this->disallowedPreferences );
447
448        $isAllowedClass = isset( $info['class'] )
449            && in_array( $info['class'], $this->allowedClasses );
450
451        return $isAllowedType || $isAllowedClass;
452    }
453
454    /**
455     * A convenience function to check if a preference name is for a global one.
456     * @param string $name The name to check.
457     * @return bool
458     */
459    public static function isGlobalPrefName( $name ) {
460        return str_ends_with( $name, static::GLOBAL_EXCEPTION_SUFFIX );
461    }
462
463    /**
464     * A convenience function to check if a preference name is for a local-exception preference.
465     * @param string $name The name to check.
466     * @return bool
467     */
468    public static function isLocalPrefName( $name ) {
469        return str_ends_with( $name, static::LOCAL_EXCEPTION_SUFFIX );
470    }
471
472    /**
473     * Checks if the user is globalized.
474     * @param UserIdentity $user
475     * @return bool
476     */
477    public function isUserGlobalized( UserIdentity $user ) {
478        $utils = MediaWikiServices::getInstance()->getUserIdentityUtils();
479        return $utils->isNamed( $user ) && $this->getUserID( $user ) !== 0;
480    }
481
482    /**
483     * Gets the user's ID that we're using in the table
484     * Returns 0 if the user is not global
485     * @param UserIdentity $user
486     * @return int
487     */
488    public function getUserID( UserIdentity $user ) {
489        return MediaWikiServices::getInstance()->getCentralIdLookupFactory()->getLookup()
490            ->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
491    }
492
493    /**
494     * Get the user's global preferences.
495     * @param UserIdentity $user
496     * @param bool $skipCache Whether the preferences should be loaded strictly from DB
497     * @return false|string[] Array keyed by preference name, or false if not found.
498     */
499    public function getGlobalPreferencesValues( UserIdentity $user, $skipCache = false ) {
500        $id = $this->getUserID( $user );
501        if ( !$id ) {
502            $this->logger->warning( "Couldn't find a global ID for user {user}",
503                [ 'user' => $user->getName() ]
504            );
505            return false;
506        }
507        $storage = $this->makeStorage( $user );
508        return $storage->load( $skipCache );
509    }
510
511    /**
512     * Save the user's global preferences.
513     * @param User $user
514     * @param array $newGlobalPrefs Array keyed by preference name.
515     * @param IContextSource $context The request context.
516     * @param string[] $checkMatricesToClear List of check matrix controls that
517     *        need their rows purged
518     * @return bool True on success, false if the user isn't global.
519     */
520    public function setGlobalPreferences(
521        User $user,
522        $newGlobalPrefs,
523        IContextSource $context,
524        array $checkMatricesToClear = []
525    ) {
526        $id = $this->getUserID( $user );
527        if ( !$id ) {
528            return false;
529        }
530
531        // Use a new instance of the current user to fetch the form descriptor because that way
532        // we're working with the previous user options and not those that are currently in the
533        // process of being saved (we only want the option names here, so don't care what the
534        // values are).
535        $userForDescriptor = User::newFromId( $user->getId() );
536
537        // Save the global options.
538        $storage = $this->makeStorage( $user );
539        $knownPrefs = array_keys( $this->getFormDescriptor( $userForDescriptor, $context ) );
540
541        $storage->save( $newGlobalPrefs, $knownPrefs, $checkMatricesToClear );
542
543        $user->clearInstanceCache();
544        return true;
545    }
546
547    /**
548     * Deletes all of a user's global preferences.
549     * Assumes that the user is globalized.
550     * @param User $user
551     */
552    public function resetGlobalUserSettings( User $user ) {
553        $this->makeStorage( $user )->delete();
554    }
555
556    /**
557     * Convenience function to check if we're on the global prefs page.
558     * @param IContextSource|null $context The context to use; if not set main request context is used.
559     * @return bool
560     */
561    public function onGlobalPrefsPage( $context = null ) {
562        $context = $context ?: RequestContext::getMain();
563        return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' );
564    }
565
566    /**
567     * Convenience function to check if we're on the local prefs page.
568     *
569     * @param IContextSource|null $context The context to use; if not set main request context is used.
570     * @return bool
571     */
572    public function onLocalPrefsPage( $context = null ) {
573        $context = $context ?: RequestContext::getMain();
574        return $context->getTitle() && $context->getTitle()->isSpecial( 'Preferences' );
575    }
576
577    /**
578     * Processes local user options before they're saved
579     *
580     * @param UserIdentity $user
581     * @param array &$modifiedOptions
582     * @param array $originalOptions
583     */
584    public function handleLocalPreferencesChange(
585        UserIdentity $user,
586        array &$modifiedOptions,
587        array $originalOptions
588    ) {
589        $shouldModify = [];
590        $mergedOptions = array_merge( $originalOptions, $modifiedOptions );
591        foreach ( $this->autoGlobals as $optName ) {
592            // $modifiedOptions can contains options that not actually modified, filter out them
593            if ( array_key_exists( $optName, $modifiedOptions ) &&
594                ( !array_key_exists( $optName, $originalOptions ) ||
595                $modifiedOptions[$optName] !== $originalOptions[$optName] ) &&
596                // And skip options that have local exceptions
597                !( $mergedOptions[$optName . static::LOCAL_EXCEPTION_SUFFIX] ?? false )
598            ) {
599                $shouldModify[$optName] = $modifiedOptions[$optName];
600            }
601        }
602        // No auto-global options are modified
603        if ( !$shouldModify ) {
604            return;
605        }
606
607        $preferencesChanged = false;
608        $globals = $this->getGlobalPreferencesValues( $user, true );
609
610        if ( $globals ) {
611            foreach ( $shouldModify as $optName => $optVal ) {
612                if ( array_key_exists( $optName, $globals ) ) {
613                    $globals[$optName] = $optVal;
614                    $preferencesChanged = true;
615                }
616            }
617
618            if ( $preferencesChanged ) {
619                $this->makeStorage( $user )->save( $globals, array_keys( $globals ) );
620            }
621        }
622    }
623
624    /**
625     * Factory for preference storage
626     *
627     * @param UserIdentity $user
628     * @return Storage
629     */
630    protected function makeStorage( UserIdentity $user ) {
631        $id = $this->getUserID( $user );
632        if ( !$id ) {
633            throw new LogicException( 'User not set or is not global on call to ' . __METHOD__ );
634        }
635        return new Storage( $id );
636    }
637}