Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.52% covered (danger)
37.52%
469 / 1250
6.45% covered (danger)
6.45%
2 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultPreferencesFactory
37.52% covered (danger)
37.52%
469 / 1250
6.45% covered (danger)
6.45%
2 / 31
15494.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 getSaveBlacklist
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getFormDescriptor
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 simplifyFormDescriptor
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
182
 loadPreferenceValues
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
90
 getPreferenceForField
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getOptionFromUser
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
342
 profilePreferences
80.26% covered (warning)
80.26%
305 / 380
0.00% covered (danger)
0.00%
0 / 1
71.00
 skinPreferences
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
110
 filesPreferences
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 datetimePreferences
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
12
 renderingPreferences
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
3
 editingPreferences
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
20
 rcPreferences
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
30
 watchlistPreferences
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 1
90
 searchPreferences
65.45% covered (warning)
65.45%
36 / 55
0.00% covered (danger)
0.00%
0 / 1
6.03
 sortSkinNames
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 getValidSkinNames
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 generateSkinOptions
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 getDateOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getImageSizes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getThumbSizes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validateSignature
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
182
 cleanSignature
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getForm
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
5.04
 saveFormData
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
12.04
 applyFilters
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 submitForm
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getResetKinds
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
702
 listResetKinds
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getOptionNamesForReset
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
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\Preferences;
22
23use MediaWiki\Auth\AuthManager;
24use MediaWiki\Auth\PasswordAuthenticationRequest;
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\Context\IContextSource;
27use MediaWiki\HookContainer\HookContainer;
28use MediaWiki\HookContainer\HookRunner;
29use MediaWiki\Html\Html;
30use MediaWiki\HTMLForm\Field\HTMLCheckMatrix;
31use MediaWiki\HTMLForm\Field\HTMLInfoField;
32use MediaWiki\HTMLForm\Field\HTMLMultiSelectField;
33use MediaWiki\HTMLForm\HTMLForm;
34use MediaWiki\HTMLForm\HTMLFormField;
35use MediaWiki\HTMLForm\HTMLNestedFilterable;
36use MediaWiki\Language\ILanguageConverter;
37use MediaWiki\Language\Language;
38use MediaWiki\Language\LanguageCode;
39use MediaWiki\Language\LanguageConverter;
40use MediaWiki\Languages\LanguageConverterFactory;
41use MediaWiki\Languages\LanguageNameUtils;
42use MediaWiki\Linker\LinkRenderer;
43use MediaWiki\MainConfigNames;
44use MediaWiki\MediaWikiServices;
45use MediaWiki\Message\Message;
46use MediaWiki\Output\OutputPage;
47use MediaWiki\Parser\Parser;
48use MediaWiki\Parser\ParserFactory;
49use MediaWiki\Parser\ParserOptions;
50use MediaWiki\Permissions\PermissionManager;
51use MediaWiki\SpecialPage\SpecialPage;
52use MediaWiki\Specials\SpecialWatchlist;
53use MediaWiki\Status\Status;
54use MediaWiki\Title\NamespaceInfo;
55use MediaWiki\Title\Title;
56use MediaWiki\User\Options\UserOptionsLookup;
57use MediaWiki\User\Options\UserOptionsManager;
58use MediaWiki\User\User;
59use MediaWiki\User\UserGroupManager;
60use MediaWiki\User\UserGroupMembership;
61use MediaWiki\User\UserTimeCorrection;
62use MediaWiki\Xml\Xml;
63use MessageLocalizer;
64use OOUI\ButtonWidget;
65use OOUI\FieldLayout;
66use OOUI\HtmlSnippet;
67use OOUI\LabelWidget;
68use OOUI\MessageWidget;
69use PreferencesFormOOUI;
70use Psr\Log\LoggerAwareTrait;
71use Psr\Log\NullLogger;
72use SkinFactory;
73use UnexpectedValueException;
74use Wikimedia\Rdbms\IDBAccessObject;
75
76/**
77 * This is the default implementation of PreferencesFactory.
78 */
79class DefaultPreferencesFactory implements PreferencesFactory {
80    use LoggerAwareTrait;
81
82    /** @var ServiceOptions */
83    protected $options;
84
85    /** @var Language The wiki's content language. */
86    protected $contLang;
87
88    /** @var LanguageNameUtils */
89    protected $languageNameUtils;
90
91    /** @var AuthManager */
92    protected $authManager;
93
94    /** @var LinkRenderer */
95    protected $linkRenderer;
96
97    /** @var NamespaceInfo */
98    protected $nsInfo;
99
100    /** @var PermissionManager */
101    protected $permissionManager;
102
103    /** @var ILanguageConverter */
104    private $languageConverter;
105
106    /** @var HookRunner */
107    private $hookRunner;
108
109    /** @var UserOptionsManager */
110    protected $userOptionsManager;
111
112    /** @var LanguageConverterFactory */
113    private $languageConverterFactory;
114
115    /** @var ParserFactory */
116    private $parserFactory;
117
118    /** @var SkinFactory */
119    private $skinFactory;
120
121    /** @var UserGroupManager */
122    private $userGroupManager;
123
124    /** @var SignatureValidatorFactory */
125    private $signatureValidatorFactory;
126
127    /**
128     * @internal For use by ServiceWiring
129     */
130    public const CONSTRUCTOR_OPTIONS = [
131        MainConfigNames::AllowUserCss,
132        MainConfigNames::AllowUserCssPrefs,
133        MainConfigNames::AllowUserJs,
134        MainConfigNames::DefaultSkin,
135        MainConfigNames::EmailAuthentication,
136        MainConfigNames::EmailConfirmToEdit,
137        MainConfigNames::EnableEditRecovery,
138        MainConfigNames::EnableEmail,
139        MainConfigNames::EnableUserEmail,
140        MainConfigNames::EnableUserEmailMuteList,
141        MainConfigNames::EnotifMinorEdits,
142        MainConfigNames::EnotifRevealEditorAddress,
143        MainConfigNames::EnotifUserTalk,
144        MainConfigNames::EnotifWatchlist,
145        MainConfigNames::ForceHTTPS,
146        MainConfigNames::HiddenPrefs,
147        MainConfigNames::ImageLimits,
148        MainConfigNames::LanguageCode,
149        MainConfigNames::LocalTZoffset,
150        MainConfigNames::MaxSigChars,
151        MainConfigNames::RCMaxAge,
152        MainConfigNames::RCShowWatchingUsers,
153        MainConfigNames::RCWatchCategoryMembership,
154        MainConfigNames::SearchMatchRedirectPreference,
155        MainConfigNames::SecureLogin,
156        MainConfigNames::ScriptPath,
157        MainConfigNames::SignatureValidation,
158        MainConfigNames::SkinsPreferred,
159        MainConfigNames::ThumbLimits,
160        MainConfigNames::ThumbnailNamespaces,
161    ];
162
163    /**
164     * @param ServiceOptions $options
165     * @param Language $contLang
166     * @param AuthManager $authManager
167     * @param LinkRenderer $linkRenderer
168     * @param NamespaceInfo $nsInfo
169     * @param PermissionManager $permissionManager
170     * @param ILanguageConverter $languageConverter
171     * @param LanguageNameUtils $languageNameUtils
172     * @param HookContainer $hookContainer
173     * @param UserOptionsLookup $userOptionsLookup Should be an instance of UserOptionsManager
174     * @param LanguageConverterFactory|null $languageConverterFactory
175     * @param ParserFactory|null $parserFactory
176     * @param SkinFactory|null $skinFactory
177     * @param UserGroupManager|null $userGroupManager
178     * @param SignatureValidatorFactory|null $signatureValidatorFactory
179     */
180    public function __construct(
181        ServiceOptions $options,
182        Language $contLang,
183        AuthManager $authManager,
184        LinkRenderer $linkRenderer,
185        NamespaceInfo $nsInfo,
186        PermissionManager $permissionManager,
187        ILanguageConverter $languageConverter,
188        LanguageNameUtils $languageNameUtils,
189        HookContainer $hookContainer,
190        UserOptionsLookup $userOptionsLookup,
191        ?LanguageConverterFactory $languageConverterFactory = null,
192        ?ParserFactory $parserFactory = null,
193        ?SkinFactory $skinFactory = null,
194        ?UserGroupManager $userGroupManager = null,
195        ?SignatureValidatorFactory $signatureValidatorFactory = null
196    ) {
197        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
198
199        $this->options = $options;
200        $this->contLang = $contLang;
201        $this->authManager = $authManager;
202        $this->linkRenderer = $linkRenderer;
203        $this->nsInfo = $nsInfo;
204
205        // We don't use the PermissionManager anymore, but we need to be careful
206        // removing the parameter since this class is extended by GlobalPreferencesFactory
207        // in the GlobalPreferences extension, and that class uses it
208        $this->permissionManager = $permissionManager;
209
210        $this->logger = new NullLogger();
211        $this->languageConverter = $languageConverter;
212        $this->languageNameUtils = $languageNameUtils;
213        $this->hookRunner = new HookRunner( $hookContainer );
214
215        // Don't break GlobalPreferences, fall back to global state if missing services
216        // or if passed a UserOptionsLookup that isn't UserOptionsManager
217        $services = static function () {
218            // BC hack. Use a closure so this can be unit-tested.
219            return MediaWikiServices::getInstance();
220        };
221        $this->userOptionsManager = ( $userOptionsLookup instanceof UserOptionsManager )
222            ? $userOptionsLookup
223            : $services()->getUserOptionsManager();
224        $this->languageConverterFactory = $languageConverterFactory ?? $services()->getLanguageConverterFactory();
225
226        $this->parserFactory = $parserFactory ?? $services()->getParserFactory();
227        $this->skinFactory = $skinFactory ?? $services()->getSkinFactory();
228        $this->userGroupManager = $userGroupManager ?? $services()->getUserGroupManager();
229        $this->signatureValidatorFactory = $signatureValidatorFactory
230            ?? $services()->getSignatureValidatorFactory();
231    }
232
233    /**
234     * @inheritDoc
235     */
236    public function getSaveBlacklist() {
237        return [
238            'realname',
239            'emailaddress',
240        ];
241    }
242
243    /**
244     * @param User $user
245     * @param IContextSource $context
246     * @return array
247     */
248    public function getFormDescriptor( User $user, IContextSource $context ) {
249        $preferences = [];
250
251        OutputPage::setupOOUI(
252            strtolower( $context->getSkin()->getSkinName() ),
253            $context->getLanguage()->getDir()
254        );
255
256        $this->profilePreferences( $user, $context, $preferences );
257        $this->skinPreferences( $user, $context, $preferences );
258        $this->datetimePreferences( $user, $context, $preferences );
259        $this->filesPreferences( $context, $preferences );
260        $this->renderingPreferences( $user, $context, $preferences );
261        $this->editingPreferences( $user, $context, $preferences );
262        $this->rcPreferences( $user, $context, $preferences );
263        $this->watchlistPreferences( $user, $context, $preferences );
264        $this->searchPreferences( $context, $preferences );
265
266        $this->hookRunner->onGetPreferences( $user, $preferences );
267
268        $this->loadPreferenceValues( $user, $context, $preferences );
269        $this->logger->debug( "Created form descriptor for user '{$user->getName()}'" );
270        return $preferences;
271    }
272
273    /**
274     * Simplify form descriptor for validation or something similar.
275     *
276     * @param array $descriptor HTML form descriptor.
277     * @return array
278     */
279    public static function simplifyFormDescriptor( array $descriptor ) {
280        foreach ( $descriptor as $name => &$params ) {
281            // Info fields are useless and can use complicated closure to provide
282            // text, skip all of them.
283            if ( ( isset( $params['type'] ) && $params['type'] === 'info' ) ||
284                // Checking old alias for compatibility with unchanged extensions
285                ( isset( $params['class'] ) && $params['class'] === \HTMLInfoField::class ) ||
286                ( isset( $params['class'] ) && $params['class'] === HTMLInfoField::class )
287            ) {
288                unset( $descriptor[$name] );
289                continue;
290            }
291            // Message parsing is the heaviest load when constructing the field,
292            // but we just want to validate data.
293            foreach ( $params as $key => $value ) {
294                switch ( $key ) {
295                    // Special case, should be kept.
296                    case 'options-message':
297                        break;
298                    // Special case, should be transferred.
299                    case 'options-messages':
300                        unset( $params[$key] );
301                        $params['options'] = $value;
302                        break;
303                    default:
304                        if ( preg_match( '/-messages?$/', $key ) ) {
305                            // Unwanted.
306                            unset( $params[$key] );
307                        }
308                }
309            }
310        }
311        return $descriptor;
312    }
313
314    /**
315     * Loads existing values for a given array of preferences
316     * @param User $user
317     * @param IContextSource $context
318     * @param array &$defaultPreferences Array to load values for
319     * @return array|null
320     */
321    private function loadPreferenceValues( User $user, IContextSource $context, &$defaultPreferences ) {
322        // Remove preferences that wikis don't want to use
323        foreach ( $this->options->get( MainConfigNames::HiddenPrefs ) as $pref ) {
324            unset( $defaultPreferences[$pref] );
325        }
326
327        // For validation.
328        $simplified = self::simplifyFormDescriptor( $defaultPreferences );
329        $form = new HTMLForm( $simplified, $context );
330
331        $disable = !$user->isAllowed( 'editmyoptions' );
332
333        $defaultOptions = $this->userOptionsManager->getDefaultOptions( $user );
334        $userOptions = $this->userOptionsManager->getOptions( $user );
335        $this->applyFilters( $userOptions, $defaultPreferences, 'filterForForm' );
336        // Add in defaults from the user
337        foreach ( $simplified as $name => $_ ) {
338            $info = &$defaultPreferences[$name];
339            if ( $disable && !in_array( $name, $this->getSaveBlacklist() ) ) {
340                $info['disabled'] = 'disabled';
341            }
342            if ( isset( $info['default'] ) ) {
343                // Already set, no problem
344                continue;
345            }
346            $field = $form->getField( $name );
347            $globalDefault = $defaultOptions[$name] ?? null;
348            $prefFromUser = static::getPreferenceForField( $name, $field, $userOptions );
349
350            // If it validates, set it as the default
351            // FIXME: That's not how the validate() function works! Values of nested fields
352            // (e.g. CheckMatix) would be missing.
353            if ( $prefFromUser !== null && // Make sure we're not just pulling nothing
354                    $field->validate( $prefFromUser, $this->userOptionsManager->getOptions( $user ) ) === true ) {
355                $info['default'] = $prefFromUser;
356            } elseif ( $field->validate( $globalDefault, $this->userOptionsManager->getOptions( $user ) ) === true ) {
357                $info['default'] = $globalDefault;
358            } else {
359                $globalDefault = json_encode( $globalDefault );
360                throw new UnexpectedValueException(
361                    "Default '$globalDefault' is invalid for preference $name of user " . $user->getName()
362                );
363            }
364        }
365
366        return $defaultPreferences;
367    }
368
369    /**
370     * Get preference values for the 'default' param of html form descriptor, compatible
371     * with nested fields.
372     *
373     * @since 1.41
374     * @param string $name
375     * @param HTMLFormField $field
376     * @param array $userOptions
377     * @return array|string
378     */
379    public static function getPreferenceForField( $name, HTMLFormField $field, array $userOptions ) {
380        $val = $userOptions[$name] ?? null;
381
382        if ( $field instanceof HTMLNestedFilterable ) {
383            $val = [];
384            $prefix = $field->mParams['prefix'] ?? $name;
385            // Fetch all possible preference keys of the given field on this wiki.
386            $keys = array_keys( $field->filterDataForSubmit( [] ) );
387            foreach ( $keys as $key ) {
388                if ( $userOptions[$prefix . $key] ?? false ) {
389                    $val[] = $key;
390                }
391            }
392        }
393
394        return $val;
395    }
396
397    /**
398     * Pull option from a user account. Handles stuff like array-type preferences.
399     *
400     * @deprecated since 1.41; Use getPreferenceForField() instead.
401     * @param string $name
402     * @param array $info
403     * @param array $userOptions
404     * @return array|string
405     */
406    protected function getOptionFromUser( $name, $info, array $userOptions ) {
407        $val = $userOptions[$name] ?? null;
408
409        // Handling for multiselect preferences
410        if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
411            // Checking old alias for compatibility with unchanged extensions
412            ( isset( $info['class'] ) && $info['class'] === \HTMLMultiSelectField::class ) ||
413            ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
414        ) {
415            $options = HTMLFormField::flattenOptions( $info['options-messages'] ?? $info['options'] );
416            $prefix = $info['prefix'] ?? $name;
417            $val = [];
418
419            foreach ( $options as $value ) {
420                if ( $userOptions["$prefix$value"] ?? false ) {
421                    $val[] = $value;
422                }
423            }
424        }
425
426        // Handling for checkmatrix preferences
427        if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
428            // Checking old alias for compatibility with unchanged extensions
429            ( isset( $info['class'] ) && $info['class'] === \HTMLCheckMatrix::class ) ||
430            ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
431        ) {
432            $columns = HTMLFormField::flattenOptions( $info['columns'] );
433            $rows = HTMLFormField::flattenOptions( $info['rows'] );
434            $prefix = $info['prefix'] ?? $name;
435            $val = [];
436
437            foreach ( $columns as $column ) {
438                foreach ( $rows as $row ) {
439                    if ( $userOptions["$prefix$column-$row"] ?? false ) {
440                        $val[] = "$column-$row";
441                    }
442                }
443            }
444        }
445
446        return $val;
447    }
448
449    /**
450     * @todo Inject user Language instead of using context.
451     * @param User $user
452     * @param IContextSource $context
453     * @param array &$defaultPreferences
454     * @return void
455     */
456    protected function profilePreferences(
457        User $user, IContextSource $context, &$defaultPreferences
458    ) {
459        // retrieving user name for GENDER and misc.
460        $userName = $user->getName();
461
462        // Information panel
463        $defaultPreferences['username'] = [
464            'type' => 'info',
465            'label-message' => [ 'username', $userName ],
466            'default' => $userName,
467            'section' => 'personal/info',
468        ];
469
470        $lang = $context->getLanguage();
471
472        // Get groups to which the user belongs, Skip the default * group, seems useless here
473        $userEffectiveGroups = array_diff(
474            $this->userGroupManager->getUserEffectiveGroups( $user ),
475            [ '*' ]
476        );
477        $defaultPreferences['usergroups'] = [
478            'type' => 'info',
479            'label-message' => [ 'prefs-memberingroups',
480                Message::numParam( count( $userEffectiveGroups ) ), $userName ],
481            'default' => function () use ( $user, $userEffectiveGroups, $context, $lang, $userName ) {
482                $userGroupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
483                $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
484                foreach ( $userEffectiveGroups as $ueg ) {
485                    $groupStringOrObject = $userGroupMemberships[$ueg] ?? $ueg;
486
487                    $userG = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context );
488                    $userM = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context, $userName );
489
490                    // Store expiring groups separately, so we can place them before non-expiring
491                    // groups in the list. This is to avoid the ambiguity of something like
492                    // "administrator, bureaucrat (until X date)" -- users might wonder whether the
493                    // expiry date applies to both groups, or just the last one
494                    if ( $groupStringOrObject instanceof UserGroupMembership &&
495                        $groupStringOrObject->getExpiry()
496                    ) {
497                        $userTempGroups[] = $userG;
498                        $userTempMembers[] = $userM;
499                    } else {
500                        $userGroups[] = $userG;
501                        $userMembers[] = $userM;
502                    }
503                }
504                sort( $userGroups );
505                sort( $userMembers );
506                sort( $userTempGroups );
507                sort( $userTempMembers );
508                $userGroups = array_merge( $userTempGroups, $userGroups );
509                $userMembers = array_merge( $userTempMembers, $userMembers );
510                return $context->msg( 'prefs-memberingroups-type' )
511                    ->rawParams( $lang->commaList( $userGroups ), $lang->commaList( $userMembers ) )
512                    ->escaped();
513            },
514            'raw' => true,
515            'section' => 'personal/info',
516        ];
517
518        $contribTitle = SpecialPage::getTitleFor( "Contributions", $userName );
519        $formattedEditCount = $lang->formatNum( $user->getEditCount() );
520        $editCount = $this->linkRenderer->makeLink( $contribTitle, $formattedEditCount );
521
522        $defaultPreferences['editcount'] = [
523            'type' => 'info',
524            'raw' => true,
525            'label-message' => 'prefs-edits',
526            'default' => $editCount,
527            'section' => 'personal/info',
528        ];
529
530        if ( $user->getRegistration() ) {
531            $displayUser = $context->getUser();
532            $userRegistration = $user->getRegistration();
533            $defaultPreferences['registrationdate'] = [
534                'type' => 'info',
535                'label-message' => 'prefs-registration',
536                'default' => $context->msg(
537                    'prefs-registration-date-time',
538                    $lang->userTimeAndDate( $userRegistration, $displayUser ),
539                    $lang->userDate( $userRegistration, $displayUser ),
540                    $lang->userTime( $userRegistration, $displayUser )
541                )->text(),
542                'section' => 'personal/info',
543            ];
544        }
545
546        $canViewPrivateInfo = $user->isAllowed( 'viewmyprivateinfo' );
547        $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
548
549        // Actually changeable stuff
550        $defaultPreferences['realname'] = [
551            // (not really "private", but still shouldn't be edited without permission)
552            'type' => $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'realname' )
553                ? 'text' : 'info',
554            'default' => $user->getRealName(),
555            'section' => 'personal/info',
556            'label-message' => 'yourrealname',
557            'help-message' => 'prefs-help-realname',
558        ];
559
560        if ( $canEditPrivateInfo && $this->authManager->allowsAuthenticationDataChange(
561            new PasswordAuthenticationRequest(), false )->isGood()
562        ) {
563            $defaultPreferences['password'] = [
564                'type' => 'info',
565                'raw' => true,
566                'default' => (string)new ButtonWidget( [
567                    'href' => SpecialPage::getTitleFor( 'ChangePassword' )->getLinkURL( [
568                        'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
569                    ] ),
570                    'label' => $context->msg( 'prefs-resetpass' )->text(),
571                ] ),
572                'label-message' => 'yourpassword',
573                // email password reset feature only works for users that have an email set up
574                'help' => $user->getEmail()
575                    ? $context->msg( 'prefs-help-yourpassword',
576                        '[[#mw-prefsection-personal-email|{{int:prefs-email}}]]' )->parse()
577                    : '',
578                'section' => 'personal/info',
579            ];
580        }
581        // Only show prefershttps if secure login is turned on
582        if ( !$this->options->get( MainConfigNames::ForceHTTPS )
583            && $this->options->get( MainConfigNames::SecureLogin )
584        ) {
585            $defaultPreferences['prefershttps'] = [
586                'type' => 'toggle',
587                'label-message' => 'tog-prefershttps',
588                'help-message' => 'prefs-help-prefershttps',
589                'section' => 'personal/info'
590            ];
591        }
592
593        $defaultPreferences['downloaduserdata'] = [
594            'type' => 'info',
595            'raw' => true,
596            'label-message' => 'prefs-user-downloaddata-label',
597            'default' => Html::element(
598                'a',
599                [
600                    'href' => $this->options->get( MainConfigNames::ScriptPath ) .
601                        '/api.php?action=query&meta=userinfo&uiprop=*&formatversion=2',
602                ],
603                $context->msg( 'prefs-user-downloaddata-info' )->text()
604            ),
605            'help-message' => [ 'prefs-user-downloaddata-help-message', urlencode( $user->getTitleKey() ) ],
606            'section' => 'personal/info',
607        ];
608
609        $defaultPreferences['restoreprefs'] = [
610            'type' => 'info',
611            'raw' => true,
612            'label-message' => 'prefs-user-restoreprefs-label',
613            'default' => Html::element(
614                'a',
615                [
616                    'href' => SpecialPage::getTitleFor( 'Preferences' )
617                        ->getSubpage( 'reset' )->getLocalURL()
618                ],
619                $context->msg( 'prefs-user-restoreprefs-info' )->text()
620            ),
621            'section' => 'personal/info',
622        ];
623
624        $languages = $this->languageNameUtils->getLanguageNames(
625            LanguageNameUtils::AUTONYMS,
626            LanguageNameUtils::SUPPORTED
627        );
628        $languageCode = $this->options->get( MainConfigNames::LanguageCode );
629        if ( !array_key_exists( $languageCode, $languages ) ) {
630            $languages[$languageCode] = $languageCode;
631            // Sort the array again
632            ksort( $languages );
633        }
634
635        $options = [];
636        foreach ( $languages as $code => $name ) {
637            $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
638            $options[$display] = $code;
639        }
640        $defaultPreferences['language'] = [
641            'type' => 'select',
642            'section' => 'personal/i18n',
643            'options' => $options,
644            'label-message' => 'yourlanguage',
645        ];
646
647        $neutralGenderMessage = $context->msg( 'gender-notknown' )->escaped() . (
648            !$context->msg( 'gender-unknown' )->isDisabled()
649                ? "<br>" . $context->msg( 'parentheses' )
650                    ->params( $context->msg( 'gender-unknown' )->plain() )
651                    ->escaped()
652                : ''
653        );
654
655        $defaultPreferences['gender'] = [
656            'type' => 'radio',
657            'section' => 'personal/i18n',
658            'options' => [
659                $neutralGenderMessage => 'unknown',
660                $context->msg( 'gender-female' )->escaped() => 'female',
661                $context->msg( 'gender-male' )->escaped() => 'male',
662            ],
663            'label-message' => 'yourgender',
664            'help-message' => 'prefs-help-gender',
665        ];
666
667        // see if there are multiple language variants to choose from
668        if ( !$this->languageConverterFactory->isConversionDisabled() ) {
669
670            foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
671                if ( $langCode == $this->contLang->getCode() ) {
672                    if ( !$this->languageConverter->hasVariants() ) {
673                        continue;
674                    }
675
676                    $variants = $this->languageConverter->getVariants();
677                    $variantArray = [];
678                    foreach ( $variants as $v ) {
679                        $v = str_replace( '_', '-', strtolower( $v ) );
680                        $variantArray[$v] = $lang->getVariantname( $v, false );
681                    }
682
683                    $options = [];
684                    foreach ( $variantArray as $code => $name ) {
685                        $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
686                        $options[$display] = $code;
687                    }
688
689                    $defaultPreferences['variant'] = [
690                        'label-message' => 'yourvariant',
691                        'type' => 'select',
692                        'options' => $options,
693                        'section' => 'personal/i18n',
694                        'help-message' => 'prefs-help-variant',
695                    ];
696                } else {
697                    $defaultPreferences["variant-$langCode"] = [
698                        'type' => 'api',
699                    ];
700                }
701            }
702        }
703
704        // show a preview of the old signature first
705        $oldsigWikiText = $this->parserFactory->getInstance()->preSaveTransform(
706            '~~~',
707            $context->getTitle(),
708            $user,
709            ParserOptions::newFromContext( $context )
710        );
711        $oldsigHTML = Parser::stripOuterParagraph(
712            $context->getOutput()->parseAsContent( $oldsigWikiText )
713        );
714        $signatureFieldConfig = [];
715        // Validate existing signature and show a message about it
716        $signature = $this->userOptionsManager->getOption( $user, 'nickname' );
717        $useFancySig = $this->userOptionsManager->getBoolOption( $user, 'fancysig' );
718        if ( $useFancySig && $signature !== '' ) {
719            $parserOpts = ParserOptions::newFromContext( $context );
720            $validator = $this->signatureValidatorFactory
721                ->newSignatureValidator( $user, $context, $parserOpts );
722            $signatureErrors = $validator->validateSignature( $signature );
723            if ( $signatureErrors ) {
724                $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
725                $oldsigHTML .= '<p><strong>' .
726                    // Messages used here:
727                    // * prefs-signature-invalid-warning
728                    // * prefs-signature-invalid-new
729                    // * prefs-signature-invalid-disallow
730                    $context->msg( "prefs-signature-invalid-$sigValidation" )->parse() .
731                    '</strong></p>';
732
733                // On initial page load, show the warnings as well
734                // (when posting, you get normal validation errors instead)
735                foreach ( $signatureErrors as &$sigError ) {
736                    $sigError = new HtmlSnippet( $sigError );
737                }
738                if ( !$context->getRequest()->wasPosted() ) {
739                    $signatureFieldConfig = [
740                        'warnings' => $sigValidation !== 'disallow' ? $signatureErrors : null,
741                        'errors' => $sigValidation === 'disallow' ? $signatureErrors : null,
742                    ];
743                }
744            }
745        }
746        $defaultPreferences['oldsig'] = [
747            'type' => 'info',
748            // Normally HTMLFormFields do not display warnings, so we need to use 'rawrow'
749            // and provide the entire OOUI\FieldLayout here
750            'rawrow' => true,
751            'default' => new FieldLayout(
752                new LabelWidget( [
753                    'label' => new HtmlSnippet( $oldsigHTML ),
754                ] ),
755                [
756                    'align' => 'top',
757                    'label' => new HtmlSnippet( $context->msg( 'tog-oldsig' )->parse() )
758                ] + $signatureFieldConfig
759            ),
760            'section' => 'personal/signature',
761        ];
762        $defaultPreferences['nickname'] = [
763            'type' => $this->authManager->allowsPropertyChange( 'nickname' ) ? 'text' : 'info',
764            'maxlength' => $this->options->get( MainConfigNames::MaxSigChars ),
765            'label-message' => 'yournick',
766            'validation-callback' => function ( $signature, $alldata, HTMLForm $form ) {
767                return $this->validateSignature( $signature, $alldata, $form );
768            },
769            'section' => 'personal/signature',
770            'filter-callback' => function ( $signature, array $alldata, HTMLForm $form ) {
771                return $this->cleanSignature( $signature, $alldata, $form );
772            },
773        ];
774        $defaultPreferences['fancysig'] = [
775            'type' => 'toggle',
776            'label-message' => 'tog-fancysig',
777            // show general help about signature at the bottom of the section
778            'help-message' => 'prefs-help-signature',
779            'section' => 'personal/signature'
780        ];
781
782        // Email preferences
783        if ( $this->options->get( MainConfigNames::EnableEmail ) ) {
784            if ( $canViewPrivateInfo ) {
785                $helpMessages = [];
786                $helpMessages[] = $this->options->get( MainConfigNames::EmailConfirmToEdit )
787                        ? 'prefs-help-email-required'
788                        : 'prefs-help-email';
789
790                if ( $this->options->get( MainConfigNames::EnableUserEmail ) ) {
791                    // additional messages when users can send email to each other
792                    $helpMessages[] = 'prefs-help-email-others';
793                }
794
795                $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
796                if ( $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'emailaddress' ) ) {
797                    $button = new ButtonWidget( [
798                        'href' => SpecialPage::getTitleFor( 'ChangeEmail' )->getLinkURL( [
799                            'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
800                        ] ),
801                        'label' =>
802                            $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
803                    ] );
804
805                    $emailAddress .= $emailAddress == '' ? $button : ( '<br />' . $button );
806                }
807
808                $defaultPreferences['emailaddress'] = [
809                    'type' => 'info',
810                    'raw' => true,
811                    'default' => $emailAddress,
812                    'label-message' => 'youremail',
813                    'section' => 'personal/email',
814                    'help-messages' => $helpMessages,
815                    // 'cssclass' chosen below
816                ];
817            }
818
819            $disableEmailPrefs = false;
820
821            $defaultPreferences['requireemail'] = [
822                'type' => 'toggle',
823                'label-message' => 'tog-requireemail',
824                'help-message' => 'prefs-help-requireemail',
825                'section' => 'personal/email',
826                'disabled' => !$user->getEmail(),
827            ];
828
829            if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
830                if ( $user->getEmail() ) {
831                    if ( $user->getEmailAuthenticationTimestamp() ) {
832                        // date and time are separate parameters to facilitate localisation.
833                        // $time is kept for backward compat reasons.
834                        // 'emailauthenticated' is also used in SpecialConfirmemail.php
835                        $displayUser = $context->getUser();
836                        $emailTimestamp = $user->getEmailAuthenticationTimestamp();
837                        $time = $lang->userTimeAndDate( $emailTimestamp, $displayUser );
838                        $d = $lang->userDate( $emailTimestamp, $displayUser );
839                        $t = $lang->userTime( $emailTimestamp, $displayUser );
840                        $emailauthenticated = $context->msg( 'emailauthenticated',
841                            $time, $d, $t )->parse() . '<br />';
842                        $emailauthenticationclass = 'mw-email-authenticated';
843                    } else {
844                        $disableEmailPrefs = true;
845                        $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '<br />' .
846                            new ButtonWidget( [
847                                'href' => SpecialPage::getTitleFor( 'Confirmemail' )->getLinkURL(),
848                                'label' => $context->msg( 'emailconfirmlink' )->text(),
849                            ] );
850                        $emailauthenticationclass = "mw-email-not-authenticated";
851                    }
852                } else {
853                    $disableEmailPrefs = true;
854                    $emailauthenticated = $context->msg( 'noemailprefs' )->escaped();
855                    $emailauthenticationclass = 'mw-email-none';
856                }
857
858                if ( $canViewPrivateInfo ) {
859                    $defaultPreferences['emailauthentication'] = [
860                        'type' => 'info',
861                        'raw' => true,
862                        'section' => 'personal/email',
863                        'label-message' => 'prefs-emailconfirm-label',
864                        'default' => $emailauthenticated,
865                        // Apply the same CSS class used on the input to the message:
866                        'cssclass' => $emailauthenticationclass,
867                    ];
868                }
869            }
870
871            if ( $this->options->get( MainConfigNames::EnableUserEmail ) &&
872                $user->isAllowed( 'sendemail' )
873            ) {
874                $defaultPreferences['disablemail'] = [
875                    'id' => 'wpAllowEmail',
876                    'type' => 'toggle',
877                    'invert' => true,
878                    'section' => 'personal/email',
879                    'label-message' => 'allowemail',
880                    'disabled' => $disableEmailPrefs,
881                ];
882
883                $defaultPreferences['email-allow-new-users'] = [
884                    'id' => 'wpAllowEmailFromNewUsers',
885                    'type' => 'toggle',
886                    'section' => 'personal/email',
887                    'label-message' => 'email-allow-new-users-label',
888                    'disabled' => $disableEmailPrefs,
889                    'disable-if' => [ '!==', 'disablemail', '1' ],
890                ];
891
892                $defaultPreferences['ccmeonemails'] = [
893                    'type' => 'toggle',
894                    'section' => 'personal/email',
895                    'label-message' => 'tog-ccmeonemails',
896                    'disabled' => $disableEmailPrefs,
897                ];
898
899                if ( $this->options->get( MainConfigNames::EnableUserEmailMuteList ) ) {
900                    $defaultPreferences['email-blacklist'] = [
901                        'type' => 'usersmultiselect',
902                        'label-message' => 'email-mutelist-label',
903                        'section' => 'personal/email',
904                        'disabled' => $disableEmailPrefs,
905                        'filter' => MultiUsernameFilter::class,
906                        'excludetemp' => true,
907                    ];
908                }
909            }
910
911            if ( $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
912                $defaultPreferences['enotifwatchlistpages'] = [
913                    'type' => 'toggle',
914                    'section' => 'personal/email',
915                    'label-message' => 'tog-enotifwatchlistpages',
916                    'disabled' => $disableEmailPrefs,
917                ];
918            }
919            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ) {
920                $defaultPreferences['enotifusertalkpages'] = [
921                    'type' => 'toggle',
922                    'section' => 'personal/email',
923                    'label-message' => 'tog-enotifusertalkpages',
924                    'disabled' => $disableEmailPrefs,
925                ];
926            }
927            if ( $this->options->get( MainConfigNames::EnotifUserTalk ) ||
928            $this->options->get( MainConfigNames::EnotifWatchlist ) ) {
929                if ( $this->options->get( MainConfigNames::EnotifMinorEdits ) ) {
930                    $defaultPreferences['enotifminoredits'] = [
931                        'type' => 'toggle',
932                        'section' => 'personal/email',
933                        'label-message' => 'tog-enotifminoredits',
934                        'disabled' => $disableEmailPrefs,
935                    ];
936                }
937
938                if ( $this->options->get( MainConfigNames::EnotifRevealEditorAddress ) ) {
939                    $defaultPreferences['enotifrevealaddr'] = [
940                        'type' => 'toggle',
941                        'section' => 'personal/email',
942                        'label-message' => 'tog-enotifrevealaddr',
943                        'disabled' => $disableEmailPrefs,
944                    ];
945                }
946            }
947        }
948    }
949
950    /**
951     * @param User $user
952     * @param IContextSource $context
953     * @param array &$defaultPreferences
954     * @return void
955     */
956    protected function skinPreferences( User $user, IContextSource $context, &$defaultPreferences ) {
957        // Skin selector, if there is at least one valid skin
958        $validSkinNames = $this->getValidSkinNames( $user, $context );
959        if ( $validSkinNames ) {
960            $defaultPreferences['skin'] = [
961                // @phan-suppress-next-line SecurityCheck-XSS False +ve, label is escaped in generateSkinOptions()
962                'type' => 'radio',
963                'options' => $this->generateSkinOptions( $user, $context, $validSkinNames ),
964                'section' => 'rendering/skin',
965            ];
966            $hideCond = [ 'AND' ];
967            foreach ( $validSkinNames as $skinName => $_ ) {
968                $options = $this->skinFactory->getSkinOptions( $skinName );
969                if ( $options['responsive'] ?? false ) {
970                    $hideCond[] = [ '!==', 'skin', $skinName ];
971                }
972            }
973            if ( $hideCond === [ 'AND' ] ) {
974                $hideCond = [];
975            }
976            $defaultPreferences['skin-responsive'] = [
977                'type' => 'check',
978                'label-message' => 'prefs-skin-responsive',
979                'section' => 'rendering/skin/skin-prefs',
980                'help-message' => 'prefs-help-skin-responsive',
981                'hide-if' => $hideCond,
982            ];
983        }
984
985        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
986        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
987        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
988        // Create links to user CSS/JS pages for all skins.
989        // This code is basically copied from generateSkinOptions().
990        // @todo Refactor this and the similar code in generateSkinOptions().
991        if ( $allowUserCss || $allowUserJs ) {
992            if ( $safeMode ) {
993                $defaultPreferences['customcssjs-safemode'] = [
994                    'type' => 'info',
995                    'raw' => true,
996                    'rawrow' => true,
997                    'section' => 'rendering/skin',
998                    'default' => new FieldLayout(
999                        new MessageWidget( [
1000                            'label' => new HtmlSnippet( $context->msg( 'prefs-custom-cssjs-safemode' )->parse() ),
1001                            'type' => 'warning',
1002                        ] )
1003                    ),
1004                ];
1005            } else {
1006                $linkTools = [];
1007                $userName = $user->getName();
1008
1009                if ( $allowUserCss ) {
1010                    $cssPage = Title::makeTitleSafe( NS_USER, $userName . '/common.css' );
1011                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
1012                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
1013                }
1014
1015                if ( $allowUserJs ) {
1016                    $jsPage = Title::makeTitleSafe( NS_USER, $userName . '/common.js' );
1017                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
1018                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
1019                }
1020
1021                $defaultPreferences['commoncssjs'] = [
1022                    'type' => 'info',
1023                    'raw' => true,
1024                    'default' => $context->getLanguage()->pipeList( $linkTools ),
1025                    'label-message' => 'prefs-common-config',
1026                    'section' => 'rendering/skin',
1027                ];
1028            }
1029        }
1030    }
1031
1032    /**
1033     * @param IContextSource $context
1034     * @param array &$defaultPreferences
1035     */
1036    protected function filesPreferences( IContextSource $context, &$defaultPreferences ) {
1037        $defaultPreferences['imagesize'] = [
1038            'type' => 'select',
1039            'options' => $this->getImageSizes( $context ),
1040            'label-message' => 'imagemaxsize',
1041            'section' => 'rendering/files',
1042        ];
1043        $defaultPreferences['thumbsize'] = [
1044            'type' => 'select',
1045            'options' => $this->getThumbSizes( $context ),
1046            'label-message' => 'thumbsize',
1047            'section' => 'rendering/files',
1048        ];
1049    }
1050
1051    /**
1052     * @param User $user
1053     * @param IContextSource $context
1054     * @param array &$defaultPreferences
1055     * @return void
1056     */
1057    protected function datetimePreferences(
1058        User $user, IContextSource $context, &$defaultPreferences
1059    ) {
1060        $dateOptions = $this->getDateOptions( $context );
1061        if ( $dateOptions ) {
1062            $defaultPreferences['date'] = [
1063                'type' => 'radio',
1064                'options' => $dateOptions,
1065                'section' => 'rendering/dateformat',
1066            ];
1067        }
1068
1069        // Info
1070        $now = wfTimestampNow();
1071        $lang = $context->getLanguage();
1072        $nowlocal = Xml::element( 'span', [ 'id' => 'wpLocalTime' ],
1073            $lang->userTime( $now, $user ) );
1074        $nowserver = $lang->userTime( $now, $user,
1075                [ 'format' => false, 'timecorrection' => false ] ) .
1076            Html::hidden( 'wpServerTime', (int)substr( $now, 8, 2 ) * 60 + (int)substr( $now, 10, 2 ) );
1077
1078        $defaultPreferences['nowserver'] = [
1079            'type' => 'info',
1080            'raw' => 1,
1081            'label-message' => 'servertime',
1082            'default' => $nowserver,
1083            'section' => 'rendering/timeoffset',
1084        ];
1085
1086        $defaultPreferences['nowlocal'] = [
1087            'type' => 'info',
1088            'raw' => 1,
1089            'label-message' => 'localtime',
1090            'default' => $nowlocal,
1091            'section' => 'rendering/timeoffset',
1092        ];
1093
1094        $userTimeCorrection = (string)$this->userOptionsManager->getOption( $user, 'timecorrection' );
1095        // This value should already be normalized by UserTimeCorrection, so it should always be valid and not
1096        // in the legacy format. However, let's be sure about that and normalize it again.
1097        // Also, recompute the offset because it can change with DST.
1098        $userTimeCorrectionObj = new UserTimeCorrection(
1099            $userTimeCorrection,
1100            null,
1101            $this->options->get( MainConfigNames::LocalTZoffset )
1102        );
1103
1104        if ( $userTimeCorrectionObj->getCorrectionType() === UserTimeCorrection::OFFSET ) {
1105            $tzDefault = UserTimeCorrection::formatTimezoneOffset( $userTimeCorrectionObj->getTimeOffset() );
1106        } else {
1107            $tzDefault = $userTimeCorrectionObj->toString();
1108        }
1109
1110        $defaultPreferences['timecorrection'] = [
1111            'type' => 'timezone',
1112            'label-message' => 'timezonelegend',
1113            'default' => $tzDefault,
1114            'size' => 20,
1115            'section' => 'rendering/timeoffset',
1116            'id' => 'wpTimeCorrection',
1117            'filter' => TimezoneFilter::class,
1118        ];
1119    }
1120
1121    /**
1122     * @param User $user
1123     * @param MessageLocalizer $l10n
1124     * @param array &$defaultPreferences
1125     */
1126    protected function renderingPreferences(
1127        User $user,
1128        MessageLocalizer $l10n,
1129        &$defaultPreferences
1130    ) {
1131        // Diffs
1132        $defaultPreferences['diffonly'] = [
1133            'type' => 'toggle',
1134            'section' => 'rendering/diffs',
1135            'label-message' => 'tog-diffonly',
1136        ];
1137        $defaultPreferences['norollbackdiff'] = [
1138            'type' => 'toggle',
1139            'section' => 'rendering/diffs',
1140            'label-message' => 'tog-norollbackdiff',
1141        ];
1142        $defaultPreferences['diff-type'] = [
1143            'type' => 'api',
1144        ];
1145
1146        // Page Rendering
1147        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1148            $defaultPreferences['underline'] = [
1149                'type' => 'select',
1150                'options' => [
1151                    $l10n->msg( 'underline-never' )->text() => 0,
1152                    $l10n->msg( 'underline-always' )->text() => 1,
1153                    $l10n->msg( 'underline-default' )->text() => 2,
1154                ],
1155                'label-message' => 'tog-underline',
1156                'section' => 'rendering/advancedrendering',
1157            ];
1158        }
1159
1160        $defaultPreferences['showhiddencats'] = [
1161            'type' => 'toggle',
1162            'section' => 'rendering/advancedrendering',
1163            'label-message' => 'tog-showhiddencats'
1164        ];
1165
1166        if ( $user->isAllowed( 'rollback' ) ) {
1167            $defaultPreferences['showrollbackconfirmation'] = [
1168                'type' => 'toggle',
1169                'section' => 'rendering/advancedrendering',
1170                'label-message' => 'tog-showrollbackconfirmation',
1171            ];
1172        }
1173
1174        $defaultPreferences['forcesafemode'] = [
1175            'type' => 'toggle',
1176            'section' => 'rendering/advancedrendering',
1177            'label-message' => 'tog-forcesafemode',
1178            'help-message' => 'prefs-help-forcesafemode'
1179        ];
1180    }
1181
1182    /**
1183     * @param User $user
1184     * @param MessageLocalizer $l10n
1185     * @param array &$defaultPreferences
1186     */
1187    protected function editingPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1188        $defaultPreferences['editsectiononrightclick'] = [
1189            'type' => 'toggle',
1190            'section' => 'editing/advancedediting',
1191            'label-message' => 'tog-editsectiononrightclick',
1192        ];
1193        $defaultPreferences['editondblclick'] = [
1194            'type' => 'toggle',
1195            'section' => 'editing/advancedediting',
1196            'label-message' => 'tog-editondblclick',
1197        ];
1198
1199        if ( $this->options->get( MainConfigNames::AllowUserCssPrefs ) ) {
1200            $defaultPreferences['editfont'] = [
1201                'type' => 'select',
1202                'section' => 'editing/editor',
1203                'label-message' => 'editfont-style',
1204                'options' => [
1205                    $l10n->msg( 'editfont-monospace' )->text() => 'monospace',
1206                    $l10n->msg( 'editfont-sansserif' )->text() => 'sans-serif',
1207                    $l10n->msg( 'editfont-serif' )->text() => 'serif',
1208                ]
1209            ];
1210        }
1211
1212        if ( $user->isAllowed( 'minoredit' ) ) {
1213            $defaultPreferences['minordefault'] = [
1214                'type' => 'toggle',
1215                'section' => 'editing/editor',
1216                'label-message' => 'tog-minordefault',
1217            ];
1218        }
1219
1220        $defaultPreferences['forceeditsummary'] = [
1221            'type' => 'toggle',
1222            'section' => 'editing/editor',
1223            'label-message' => 'tog-forceeditsummary',
1224        ];
1225
1226        // T350653
1227        if ( $this->options->get( MainConfigNames::EnableEditRecovery ) ) {
1228            $defaultPreferences['editrecovery'] = [
1229                'type' => 'toggle',
1230                'section' => 'editing/editor',
1231                'label-message' => 'tog-editrecovery',
1232                'help-message' => [
1233                    'tog-editrecovery-help',
1234                    'https://meta.wikimedia.org/wiki/Talk:Community_Wishlist_Survey_2023/Edit-recovery_feature',
1235                ],
1236            ];
1237        }
1238
1239        $defaultPreferences['useeditwarning'] = [
1240            'type' => 'toggle',
1241            'section' => 'editing/editor',
1242            'label-message' => 'tog-useeditwarning',
1243        ];
1244
1245        $defaultPreferences['previewonfirst'] = [
1246            'type' => 'toggle',
1247            'section' => 'editing/preview',
1248            'label-message' => 'tog-previewonfirst',
1249        ];
1250        $defaultPreferences['previewontop'] = [
1251            'type' => 'toggle',
1252            'section' => 'editing/preview',
1253            'label-message' => 'tog-previewontop',
1254        ];
1255        $defaultPreferences['uselivepreview'] = [
1256            'type' => 'toggle',
1257            'section' => 'editing/preview',
1258            'label-message' => 'tog-uselivepreview',
1259        ];
1260    }
1261
1262    /**
1263     * @param User $user
1264     * @param MessageLocalizer $l10n
1265     * @param array &$defaultPreferences
1266     */
1267    protected function rcPreferences( User $user, MessageLocalizer $l10n, &$defaultPreferences ) {
1268        $rcMaxAge = $this->options->get( MainConfigNames::RCMaxAge );
1269        $rcMax = ceil( $rcMaxAge / ( 3600 * 24 ) );
1270        $defaultPreferences['rcdays'] = [
1271            'type' => 'float',
1272            'label-message' => 'recentchangesdays',
1273            'section' => 'rc/displayrc',
1274            'min' => 1 / 24,
1275            'max' => $rcMax,
1276            'help-message' => [ 'recentchangesdays-max', Message::numParam( $rcMax ) ],
1277        ];
1278        $defaultPreferences['rclimit'] = [
1279            'type' => 'int',
1280            'min' => 1,
1281            'max' => 1000,
1282            'label-message' => 'recentchangescount',
1283            'help-message' => 'prefs-help-recentchangescount',
1284            'section' => 'rc/displayrc',
1285            'filter' => IntvalFilter::class,
1286        ];
1287        $defaultPreferences['usenewrc'] = [
1288            'type' => 'toggle',
1289            'label-message' => 'tog-usenewrc',
1290            'section' => 'rc/advancedrc',
1291        ];
1292        $defaultPreferences['hideminor'] = [
1293            'type' => 'toggle',
1294            'label-message' => 'tog-hideminor',
1295            'section' => 'rc/changesrc',
1296        ];
1297        $defaultPreferences['pst-cssjs'] = [
1298            'type' => 'api',
1299        ];
1300        $defaultPreferences['rcfilters-rc-collapsed'] = [
1301            'type' => 'api',
1302        ];
1303        $defaultPreferences['rcfilters-wl-collapsed'] = [
1304            'type' => 'api',
1305        ];
1306        $defaultPreferences['rcfilters-saved-queries'] = [
1307            'type' => 'api',
1308        ];
1309        $defaultPreferences['rcfilters-wl-saved-queries'] = [
1310            'type' => 'api',
1311        ];
1312        // Override RCFilters preferences for RecentChanges 'limit'
1313        $defaultPreferences['rcfilters-limit'] = [
1314            'type' => 'api',
1315        ];
1316        $defaultPreferences['rcfilters-saved-queries-versionbackup'] = [
1317            'type' => 'api',
1318        ];
1319        $defaultPreferences['rcfilters-wl-saved-queries-versionbackup'] = [
1320            'type' => 'api',
1321        ];
1322
1323        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1324            $defaultPreferences['hidecategorization'] = [
1325                'type' => 'toggle',
1326                'label-message' => 'tog-hidecategorization',
1327                'section' => 'rc/changesrc',
1328            ];
1329        }
1330
1331        if ( $user->useRCPatrol() ) {
1332            $defaultPreferences['hidepatrolled'] = [
1333                'type' => 'toggle',
1334                'section' => 'rc/changesrc',
1335                'label-message' => 'tog-hidepatrolled',
1336            ];
1337        }
1338
1339        if ( $user->useNPPatrol() ) {
1340            $defaultPreferences['newpageshidepatrolled'] = [
1341                'type' => 'toggle',
1342                'section' => 'rc/changesrc',
1343                'label-message' => 'tog-newpageshidepatrolled',
1344            ];
1345        }
1346
1347        if ( $this->options->get( MainConfigNames::RCShowWatchingUsers ) ) {
1348            $defaultPreferences['shownumberswatching'] = [
1349                'type' => 'toggle',
1350                'section' => 'rc/advancedrc',
1351                'label-message' => 'tog-shownumberswatching',
1352            ];
1353        }
1354
1355        $defaultPreferences['rcenhancedfilters-disable'] = [
1356            'type' => 'toggle',
1357            'section' => 'rc/advancedrc',
1358            'label-message' => 'rcfilters-preference-label',
1359            'help-message' => 'rcfilters-preference-help',
1360        ];
1361    }
1362
1363    /**
1364     * @param User $user
1365     * @param IContextSource $context
1366     * @param array &$defaultPreferences
1367     */
1368    protected function watchlistPreferences(
1369        User $user, IContextSource $context, &$defaultPreferences
1370    ) {
1371        $watchlistdaysMax = ceil( $this->options->get( MainConfigNames::RCMaxAge ) / ( 3600 * 24 ) );
1372
1373        if ( $user->isAllowed( 'editmywatchlist' ) ) {
1374            $editWatchlistLinks = '';
1375            $editWatchlistModes = [
1376                'edit' => [ 'subpage' => false, 'flags' => [] ],
1377                'raw' => [ 'subpage' => 'raw', 'flags' => [] ],
1378                'clear' => [ 'subpage' => 'clear', 'flags' => [ 'destructive' ] ],
1379            ];
1380            foreach ( $editWatchlistModes as $mode => $options ) {
1381                // Messages: prefs-editwatchlist-edit, prefs-editwatchlist-raw, prefs-editwatchlist-clear
1382                $editWatchlistLinks .=
1383                    new ButtonWidget( [
1384                        'href' => SpecialPage::getTitleFor( 'EditWatchlist', $options['subpage'] )->getLinkURL(),
1385                        'flags' => $options[ 'flags' ],
1386                        'label' => new HtmlSnippet(
1387                            $context->msg( "prefs-editwatchlist-{$mode}" )->parse()
1388                        ),
1389                    ] );
1390            }
1391
1392            $defaultPreferences['editwatchlist'] = [
1393                'type' => 'info',
1394                'raw' => true,
1395                'default' => $editWatchlistLinks,
1396                'label-message' => 'prefs-editwatchlist-label',
1397                'section' => 'watchlist/editwatchlist',
1398            ];
1399        }
1400
1401        $defaultPreferences['watchlistdays'] = [
1402            'type' => 'float',
1403            'min' => 1 / 24,
1404            'max' => $watchlistdaysMax,
1405            'section' => 'watchlist/displaywatchlist',
1406            'help-message' => [ 'prefs-watchlist-days-max', Message::numParam( $watchlistdaysMax ) ],
1407            'label-message' => 'prefs-watchlist-days',
1408        ];
1409        $defaultPreferences['wllimit'] = [
1410            'type' => 'int',
1411            'min' => 1,
1412            'max' => 1000,
1413            'label-message' => 'prefs-watchlist-edits',
1414            'help-message' => 'prefs-watchlist-edits-max',
1415            'section' => 'watchlist/displaywatchlist',
1416            'filter' => IntvalFilter::class,
1417        ];
1418        $defaultPreferences['extendwatchlist'] = [
1419            'type' => 'toggle',
1420            'section' => 'watchlist/advancedwatchlist',
1421            'label-message' => 'tog-extendwatchlist',
1422        ];
1423        $defaultPreferences['watchlisthideminor'] = [
1424            'type' => 'toggle',
1425            'section' => 'watchlist/changeswatchlist',
1426            'label-message' => 'tog-watchlisthideminor',
1427        ];
1428        $defaultPreferences['watchlisthidebots'] = [
1429            'type' => 'toggle',
1430            'section' => 'watchlist/changeswatchlist',
1431            'label-message' => 'tog-watchlisthidebots',
1432        ];
1433        $defaultPreferences['watchlisthideown'] = [
1434            'type' => 'toggle',
1435            'section' => 'watchlist/changeswatchlist',
1436            'label-message' => 'tog-watchlisthideown',
1437        ];
1438        $defaultPreferences['watchlisthideanons'] = [
1439            'type' => 'toggle',
1440            'section' => 'watchlist/changeswatchlist',
1441            'label-message' => 'tog-watchlisthideanons',
1442        ];
1443        $defaultPreferences['watchlisthideliu'] = [
1444            'type' => 'toggle',
1445            'section' => 'watchlist/changeswatchlist',
1446            'label-message' => 'tog-watchlisthideliu',
1447        ];
1448
1449        if ( !SpecialWatchlist::checkStructuredFilterUiEnabled( $user ) ) {
1450            $defaultPreferences['watchlistreloadautomatically'] = [
1451                'type' => 'toggle',
1452                'section' => 'watchlist/advancedwatchlist',
1453                'label-message' => 'tog-watchlistreloadautomatically',
1454            ];
1455        }
1456
1457        $defaultPreferences['watchlistunwatchlinks'] = [
1458            'type' => 'toggle',
1459            'section' => 'watchlist/advancedwatchlist',
1460            'label-message' => 'tog-watchlistunwatchlinks',
1461        ];
1462
1463        if ( $this->options->get( MainConfigNames::RCWatchCategoryMembership ) ) {
1464            $defaultPreferences['watchlisthidecategorization'] = [
1465                'type' => 'toggle',
1466                'section' => 'watchlist/changeswatchlist',
1467                'label-message' => 'tog-watchlisthidecategorization',
1468            ];
1469        }
1470
1471        if ( $user->useRCPatrol() ) {
1472            $defaultPreferences['watchlisthidepatrolled'] = [
1473                'type' => 'toggle',
1474                'section' => 'watchlist/changeswatchlist',
1475                'label-message' => 'tog-watchlisthidepatrolled',
1476            ];
1477        }
1478
1479        $watchTypes = [
1480            'edit' => 'watchdefault',
1481            'move' => 'watchmoves',
1482        ];
1483
1484        // Kinda hacky
1485        if ( $user->isAllowedAny( 'createpage', 'createtalk' ) ) {
1486            $watchTypes['read'] = 'watchcreations';
1487        }
1488
1489        // Move uncommon actions to end of list
1490        $watchTypes += [
1491            'rollback' => 'watchrollback',
1492            'upload' => 'watchuploads',
1493            'delete' => 'watchdeletion',
1494        ];
1495
1496        foreach ( $watchTypes as $action => $pref ) {
1497            if ( $user->isAllowed( $action ) ) {
1498                // Messages:
1499                // tog-watchdefault, tog-watchmoves, tog-watchdeletion, tog-watchcreations, tog-watchuploads
1500                // tog-watchrollback
1501                $defaultPreferences[$pref] = [
1502                    'type' => 'toggle',
1503                    'section' => 'watchlist/pageswatchlist',
1504                    'label-message' => "tog-$pref",
1505                ];
1506            }
1507        }
1508
1509        $defaultPreferences['watchlisttoken'] = [
1510            'type' => 'api',
1511        ];
1512
1513        $tokenButton = new ButtonWidget( [
1514            'href' => SpecialPage::getTitleFor( 'ResetTokens' )->getLinkURL( [
1515                'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
1516            ] ),
1517            'label' => $context->msg( 'prefs-watchlist-managetokens' )->text(),
1518        ] );
1519        $defaultPreferences['watchlisttoken-info'] = [
1520            'type' => 'info',
1521            'section' => 'watchlist/tokenwatchlist',
1522            'label-message' => 'prefs-watchlist-token',
1523            'help-message' => 'prefs-help-tokenmanagement',
1524            'raw' => true,
1525            'default' => (string)$tokenButton,
1526        ];
1527
1528        $defaultPreferences['wlenhancedfilters-disable'] = [
1529            'type' => 'toggle',
1530            'section' => 'watchlist/advancedwatchlist',
1531            'label-message' => 'rcfilters-watchlist-preference-label',
1532            'help-message' => 'rcfilters-watchlist-preference-help',
1533        ];
1534    }
1535
1536    /**
1537     * @param IContextSource $context
1538     * @param array &$defaultPreferences
1539     */
1540    protected function searchPreferences( $context, &$defaultPreferences ) {
1541        $defaultPreferences['search-special-page'] = [
1542            'type' => 'api',
1543        ];
1544
1545        foreach ( $this->nsInfo->getValidNamespaces() as $n ) {
1546            $defaultPreferences['searchNs' . $n] = [
1547                'type' => 'api',
1548            ];
1549        }
1550
1551        if ( $this->options->get( MainConfigNames::SearchMatchRedirectPreference ) ) {
1552            $defaultPreferences['search-match-redirect'] = [
1553                'type' => 'toggle',
1554                'section' => 'searchoptions/searchmisc',
1555                'label-message' => 'search-match-redirect-label',
1556                'help-message' => 'search-match-redirect-help',
1557            ];
1558        } else {
1559            $defaultPreferences['search-match-redirect'] = [
1560                'type' => 'api',
1561            ];
1562        }
1563
1564        $defaultPreferences['searchlimit'] = [
1565            'type' => 'int',
1566            'min' => 1,
1567            'max' => 500,
1568            'section' => 'searchoptions/searchmisc',
1569            'label-message' => 'searchlimit-label',
1570            'help-message' => $context->msg( 'searchlimit-help', 500 ),
1571            'filter' => IntvalFilter::class,
1572        ];
1573
1574        // show a preference for thumbnails from namespaces other than NS_FILE,
1575        // only when there they're actually configured to be served
1576        $thumbNamespaces = $this->options->get( MainConfigNames::ThumbnailNamespaces );
1577        $thumbNamespacesFormatted = array_combine(
1578            $thumbNamespaces,
1579            array_map(
1580                static function ( $namespaceId ) use ( $context ) {
1581                    return $namespaceId === NS_MAIN
1582                        ? $context->msg( 'blanknamespace' )->escaped()
1583                        : $context->getLanguage()->getFormattedNsText( $namespaceId );
1584                },
1585                $thumbNamespaces
1586            )
1587        );
1588        $defaultThumbNamespacesFormatted =
1589            array_intersect_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] ) ?? [];
1590        $extraThumbNamespacesFormatted =
1591            array_diff_key( $thumbNamespacesFormatted, [ NS_FILE => 1 ] );
1592        if ( $extraThumbNamespacesFormatted ) {
1593            $defaultPreferences['search-thumbnail-extra-namespaces'] = [
1594                'type' => 'toggle',
1595                'section' => 'searchoptions/searchmisc',
1596                'label-message' => 'search-thumbnail-extra-namespaces-label',
1597                'help-message' => $context->msg(
1598                    'search-thumbnail-extra-namespaces-message',
1599                    $context->getLanguage()->listToText( $extraThumbNamespacesFormatted ),
1600                    count( $extraThumbNamespacesFormatted ),
1601                    $context->getLanguage()->listToText( $defaultThumbNamespacesFormatted ),
1602                    count( $defaultThumbNamespacesFormatted )
1603                ),
1604            ];
1605        }
1606    }
1607
1608    /*
1609     * Custom skin string comparison function that takes into account current and preferred skins.
1610     *
1611     * @param string $a
1612     * @param string $b
1613     * @param string $currentSkin
1614     * @param array $preferredSkins
1615     * @return int
1616     */
1617    private static function sortSkinNames( $a, $b, $currentSkin, $preferredSkins ) {
1618        // Display the current skin first in the list
1619        if ( strcasecmp( $a, $currentSkin ) === 0 ) {
1620            return -1;
1621        }
1622        if ( strcasecmp( $b, $currentSkin ) === 0 ) {
1623            return 1;
1624        }
1625        // Display preferred skins over other skins
1626        if ( count( $preferredSkins ) ) {
1627            $aPreferred = array_search( $a, $preferredSkins );
1628            $bPreferred = array_search( $b, $preferredSkins );
1629            // Cannot use ! operator because array_search returns the
1630            // index of the array item if found (i.e. 0) and false otherwise
1631            if ( $aPreferred !== false && $bPreferred === false ) {
1632                return -1;
1633            }
1634            if ( $aPreferred === false && $bPreferred !== false ) {
1635                return 1;
1636            }
1637            // When both skins are preferred, default to the ordering
1638            // specified by the preferred skins config array
1639            if ( $aPreferred !== false && $bPreferred !== false ) {
1640                return strcasecmp( $aPreferred, $bPreferred );
1641            }
1642        }
1643        // Use normal string comparison if both strings are not preferred
1644        return strcasecmp( $a, $b );
1645    }
1646
1647    /**
1648     * Gat valid skin names for the given user, which the 'useskin' query string and user
1649     * options should be taken into account.
1650     *
1651     * @param User $user
1652     * @param IContextSource $context
1653     * @return array Associative array in the format of [ 'skin name' => 'display name' ].
1654     */
1655    private function getValidSkinNames( User $user, IContextSource $context ) {
1656        // Only show skins that aren't disabled
1657        $validSkinNames = $this->skinFactory->getAllowedSkins();
1658        $allInstalledSkins = $this->skinFactory->getInstalledSkins();
1659
1660        // Display the installed skin the user has specifically requested via useskin=….
1661        $useSkin = $context->getRequest()->getRawVal( 'useskin' );
1662        if ( $useSkin !== null && isset( $allInstalledSkins[$useSkin] )
1663            && $context->msg( "skinname-$useSkin" )->exists()
1664        ) {
1665            $validSkinNames[$useSkin] = $useSkin;
1666        }
1667
1668        // Display the skin if the user has set it as a preference already before it was hidden.
1669        $currentUserSkin = $this->userOptionsManager->getOption( $user, 'skin' );
1670        if ( isset( $allInstalledSkins[$currentUserSkin] )
1671            && $context->msg( "skinname-$currentUserSkin" )->exists()
1672        ) {
1673            $validSkinNames[$currentUserSkin] = $currentUserSkin;
1674        }
1675
1676        foreach ( $validSkinNames as $skinkey => &$skinname ) {
1677            $msg = $context->msg( "skinname-{$skinkey}" );
1678            if ( $msg->exists() ) {
1679                $skinname = htmlspecialchars( $msg->text() );
1680            }
1681        }
1682
1683        $preferredSkins = $this->options->get( MainConfigNames::SkinsPreferred );
1684        // Sort by the internal name, so that the ordering is the same for each display language,
1685        // especially if some skin names are translated to use a different alphabet and some are not.
1686        uksort( $validSkinNames, function ( $a, $b ) use ( $currentUserSkin, $preferredSkins ) {
1687            return $this->sortSkinNames( $a, $b, $currentUserSkin, $preferredSkins );
1688        } );
1689
1690        return $validSkinNames;
1691    }
1692
1693    /**
1694     * @param User $user
1695     * @param IContextSource $context
1696     * @param array $validSkinNames
1697     * @return array Text/links to display as key; $skinkey as value
1698     */
1699    protected function generateSkinOptions( User $user, IContextSource $context, array $validSkinNames ) {
1700        $ret = [];
1701
1702        $mptitle = Title::newMainPage();
1703        $previewtext = $context->msg( 'skin-preview' )->escaped();
1704        $defaultSkin = $this->options->get( MainConfigNames::DefaultSkin );
1705        $allowUserCss = $this->options->get( MainConfigNames::AllowUserCss );
1706        $allowUserJs = $this->options->get( MainConfigNames::AllowUserJs );
1707        $safeMode = $this->userOptionsManager->getOption( $user, 'forcesafemode' );
1708        $foundDefault = false;
1709        foreach ( $validSkinNames as $skinkey => $sn ) {
1710            $linkTools = [];
1711
1712            // Mark the default skin
1713            if ( strcasecmp( $skinkey, $defaultSkin ) === 0 ) {
1714                $linkTools[] = $context->msg( 'default' )->escaped();
1715                $foundDefault = true;
1716            }
1717
1718            // Create talk page link if relevant message exists.
1719            $talkPageMsg = $context->msg( "$skinkey-prefs-talkpage" );
1720            if ( $talkPageMsg->exists() ) {
1721                $linkTools[] = $talkPageMsg->parse();
1722            }
1723
1724            // Create preview link
1725            $mplink = htmlspecialchars( $mptitle->getLocalURL( [ 'useskin' => $skinkey ] ) );
1726            $linkTools[] = "<a target='_blank' href=\"$mplink\">$previewtext</a>";
1727
1728            if ( !$safeMode ) {
1729                // Create links to user CSS/JS pages
1730                // @todo Refactor this and the similar code in skinPreferences().
1731                if ( $allowUserCss ) {
1732                    $cssPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.css' );
1733                    $cssLinkText = $context->msg( 'prefs-custom-css' )->text();
1734                    $linkTools[] = $this->linkRenderer->makeLink( $cssPage, $cssLinkText );
1735                }
1736
1737                if ( $allowUserJs ) {
1738                    $jsPage = Title::makeTitleSafe( NS_USER, $user->getName() . '/' . $skinkey . '.js' );
1739                    $jsLinkText = $context->msg( 'prefs-custom-js' )->text();
1740                    $linkTools[] = $this->linkRenderer->makeLink( $jsPage, $jsLinkText );
1741                }
1742            }
1743
1744            $display = $sn . ' ' . $context->msg( 'parentheses' )
1745                ->rawParams( $context->getLanguage()->pipeList( $linkTools ) )
1746                ->escaped();
1747            $ret[$display] = $skinkey;
1748        }
1749
1750        if ( !$foundDefault ) {
1751            // If the default skin is not available, things are going to break horribly because the
1752            // default value for skin selector will not be a valid value. Let's just not show it then.
1753            return [];
1754        }
1755
1756        return $ret;
1757    }
1758
1759    /**
1760     * @param IContextSource $context
1761     * @return array
1762     */
1763    protected function getDateOptions( IContextSource $context ) {
1764        $lang = $context->getLanguage();
1765        $dateopts = $lang->getDatePreferences();
1766
1767        $ret = [];
1768
1769        if ( $dateopts ) {
1770            if ( !in_array( 'default', $dateopts ) ) {
1771                $dateopts[] = 'default'; // Make sure default is always valid T21237
1772            }
1773
1774            // FIXME KLUGE: site default might not be valid for user language
1775            global $wgDefaultUserOptions;
1776            if ( !in_array( $wgDefaultUserOptions['date'], $dateopts ) ) {
1777                $wgDefaultUserOptions['date'] = 'default';
1778            }
1779
1780            $epoch = wfTimestampNow();
1781            foreach ( $dateopts as $key ) {
1782                if ( $key == 'default' ) {
1783                    $formatted = $context->msg( 'datedefault' )->escaped();
1784                } else {
1785                    $formatted = htmlspecialchars( $lang->timeanddate( $epoch, false, $key ) );
1786                }
1787                $ret[$formatted] = $key;
1788            }
1789        }
1790        return $ret;
1791    }
1792
1793    /**
1794     * @param MessageLocalizer $l10n
1795     * @return array
1796     */
1797    protected function getImageSizes( MessageLocalizer $l10n ) {
1798        $ret = [];
1799        $pixels = $l10n->msg( 'unit-pixel' )->text();
1800
1801        foreach ( $this->options->get( MainConfigNames::ImageLimits ) as $index => $limits ) {
1802            // Note: A left-to-right marker (U+200E) is inserted, see T144386
1803            $display = "{$limits[0]}\u{200E}×{$limits[1]}$pixels";
1804            $ret[$display] = $index;
1805        }
1806
1807        return $ret;
1808    }
1809
1810    /**
1811     * @param MessageLocalizer $l10n
1812     * @return array
1813     */
1814    protected function getThumbSizes( MessageLocalizer $l10n ) {
1815        $ret = [];
1816        $pixels = $l10n->msg( 'unit-pixel' )->text();
1817
1818        foreach ( $this->options->get( MainConfigNames::ThumbLimits ) as $index => $size ) {
1819            $display = $size . $pixels;
1820            $ret[$display] = $index;
1821        }
1822
1823        return $ret;
1824    }
1825
1826    /**
1827     * @param mixed $signature
1828     * @param array $alldata
1829     * @param HTMLForm $form
1830     * @return bool|string|string[]
1831     */
1832    protected function validateSignature( $signature, $alldata, HTMLForm $form ) {
1833        $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
1834        $maxSigChars = $this->options->get( MainConfigNames::MaxSigChars );
1835        if ( is_string( $signature ) && mb_strlen( $signature ) > $maxSigChars ) {
1836            return $form->msg( 'badsiglength' )->numParams( $maxSigChars )->escaped();
1837        }
1838
1839        if ( $signature === null || $signature === '' ) {
1840            // Make sure leaving the field empty is valid, since that's used as the default (T288151).
1841            // Code using this preference in Parser::getUserSig() handles this case specially.
1842            return true;
1843        }
1844
1845        // Remaining checks only apply to fancy signatures
1846        if ( !( isset( $alldata['fancysig'] ) && $alldata['fancysig'] ) ) {
1847            return true;
1848        }
1849
1850        // HERE BE DRAGONS:
1851        //
1852        // If this value is already saved as the user's signature, treat it as valid, even if it
1853        // would be invalid to save now, and even if $wgSignatureValidation is set to 'disallow'.
1854        //
1855        // It can become invalid when we introduce new validation, or when the value just transcludes
1856        // some page containing the real signature and that page is edited (which we can't validate),
1857        // or when someone's username is changed.
1858        //
1859        // Otherwise it would be completely removed when the user opens their preferences page, which
1860        // would be very unfriendly.
1861        $user = $form->getUser();
1862        if (
1863            $signature === $this->userOptionsManager->getOption( $user, 'nickname' ) &&
1864            (bool)$alldata['fancysig'] === $this->userOptionsManager->getBoolOption( $user, 'fancysig' )
1865        ) {
1866            return true;
1867        }
1868
1869        if ( $sigValidation === 'new' || $sigValidation === 'disallow' ) {
1870            // Validate everything
1871            $parserOpts = ParserOptions::newFromContext( $form->getContext() );
1872            $validator = $this->signatureValidatorFactory
1873                ->newSignatureValidator( $user, $form->getContext(), $parserOpts );
1874            $errors = $validator->validateSignature( $signature );
1875            if ( $errors ) {
1876                return $errors;
1877            }
1878        }
1879
1880        // Quick check for mismatched HTML tags in the input.
1881        // Note that this is easily fooled by wikitext templates or bold/italic markup.
1882        // We're only keeping this until Parsoid is integrated and guaranteed to be available.
1883        if ( $this->parserFactory->getInstance()->validateSig( $signature ) === false ) {
1884            return $form->msg( 'badsig' )->escaped();
1885        }
1886
1887        return true;
1888    }
1889
1890    /**
1891     * @param string $signature
1892     * @param array $alldata
1893     * @param HTMLForm $form
1894     * @return string
1895     */
1896    protected function cleanSignature( $signature, $alldata, HTMLForm $form ) {
1897        if ( isset( $alldata['fancysig'] ) && $alldata['fancysig'] ) {
1898            $signature = $this->parserFactory->getInstance()->cleanSig( $signature );
1899        } else {
1900            // When no fancy sig used, make sure ~{3,5} get removed.
1901            $signature = Parser::cleanSigInSig( $signature );
1902        }
1903
1904        return $signature;
1905    }
1906
1907    /**
1908     * @param User $user
1909     * @param IContextSource $context
1910     * @param string $formClass
1911     * @param array $remove Array of items to remove
1912     * @return HTMLForm
1913     */
1914    public function getForm(
1915        User $user,
1916        IContextSource $context,
1917        $formClass = PreferencesFormOOUI::class,
1918        array $remove = []
1919    ) {
1920        // We use ButtonWidgets in some of the getPreferences() functions
1921        $context->getOutput()->enableOOUI();
1922
1923        // Note that the $user parameter of getFormDescriptor() is deprecated.
1924        $formDescriptor = $this->getFormDescriptor( $user, $context );
1925        if ( count( $remove ) ) {
1926            $removeKeys = array_fill_keys( $remove, true );
1927            $formDescriptor = array_diff_key( $formDescriptor, $removeKeys );
1928        }
1929
1930        // Remove type=api preferences. They are not intended for rendering in the form.
1931        foreach ( $formDescriptor as $name => $info ) {
1932            if ( isset( $info['type'] ) && $info['type'] === 'api' ) {
1933                unset( $formDescriptor[$name] );
1934            }
1935        }
1936
1937        /**
1938         * @var PreferencesFormOOUI $htmlForm
1939         */
1940        $htmlForm = new $formClass( $formDescriptor, $context, 'prefs' );
1941
1942        // This allows users to opt-in to hidden skins. While this should be discouraged and is not
1943        // discoverable, this allows users to still use hidden skins while preventing new users from
1944        // adopting unsupported skins. If no useskin=… parameter was provided, it will not show up
1945        // in the resulting URL.
1946        $htmlForm->setAction( $context->getTitle()->getLocalURL( [
1947            'useskin' => $context->getRequest()->getRawVal( 'useskin' )
1948        ] ) );
1949
1950        $htmlForm->setModifiedUser( $user );
1951        $htmlForm->setOptionsEditable( $user->isAllowed( 'editmyoptions' ) );
1952        $htmlForm->setPrivateInfoEditable( $user->isAllowed( 'editmyprivateinfo' ) );
1953        $htmlForm->setId( 'mw-prefs-form' );
1954        $htmlForm->setAutocomplete( 'off' );
1955        $htmlForm->setSubmitTextMsg( 'saveprefs' );
1956        // Used message keys: 'accesskey-preferences-save', 'tooltip-preferences-save'
1957        $htmlForm->setSubmitTooltip( 'preferences-save' );
1958        $htmlForm->setSubmitID( 'prefcontrol' );
1959        $htmlForm->setSubmitCallback(
1960            function ( array $formData, PreferencesFormOOUI $form ) use ( $formDescriptor ) {
1961                return $this->submitForm( $formData, $form, $formDescriptor );
1962            }
1963        );
1964
1965        return $htmlForm;
1966    }
1967
1968    /**
1969     * Handle the form submission if everything validated properly
1970     *
1971     * @param array $formData
1972     * @param PreferencesFormOOUI $form
1973     * @param array[] $formDescriptor
1974     * @return bool|Status|string
1975     */
1976    protected function saveFormData( $formData, PreferencesFormOOUI $form, array $formDescriptor ) {
1977        $user = $form->getModifiedUser();
1978        $hiddenPrefs = $this->options->get( MainConfigNames::HiddenPrefs );
1979        $result = true;
1980
1981        if ( !$user->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) {
1982            return Status::newFatal( 'mypreferencesprotected' );
1983        }
1984
1985        // Filter input
1986        $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
1987
1988        // Fortunately, the realname field is MUCH simpler
1989        // (not really "private", but still shouldn't be edited without permission)
1990
1991        if ( !in_array( 'realname', $hiddenPrefs )
1992            && $user->isAllowed( 'editmyprivateinfo' )
1993            && array_key_exists( 'realname', $formData )
1994        ) {
1995            $realName = $formData['realname'];
1996            $user->setRealName( $realName );
1997        }
1998
1999        if ( $user->isAllowed( 'editmyoptions' ) ) {
2000            $oldUserOptions = $this->userOptionsManager->getOptions( $user );
2001
2002            foreach ( $this->getSaveBlacklist() as $b ) {
2003                unset( $formData[$b] );
2004            }
2005
2006            // If users have saved a value for a preference which has subsequently been disabled
2007            // via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
2008            // is subsequently re-enabled
2009            foreach ( $hiddenPrefs as $pref ) {
2010                // If the user has not set a non-default value here, the default will be returned
2011                // and subsequently discarded
2012                $formData[$pref] = $this->userOptionsManager->getOption( $user, $pref, null, true );
2013            }
2014
2015            // If the user changed the rclimit preference, also change the rcfilters-rclimit preference
2016            if (
2017                isset( $formData['rclimit'] ) &&
2018                intval( $formData[ 'rclimit' ] ) !== $this->userOptionsManager->getIntOption( $user, 'rclimit' )
2019            ) {
2020                $formData['rcfilters-limit'] = $formData['rclimit'];
2021            }
2022
2023            // Keep old preferences from interfering due to back-compat code, etc.
2024            $optionsToReset = $this->getOptionNamesForReset( $user, $form->getContext(), 'unused' );
2025            $this->userOptionsManager->resetOptionsByName( $user, $optionsToReset );
2026
2027            foreach ( $formData as $key => $value ) {
2028                // If we're creating a new local override, we need to explicitly pass
2029                // GLOBAL_OVERRIDE to setOption(), otherwise the update would be ignored
2030                // due to the conflicting global option.
2031                $except = !empty( $formData[$key . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX] );
2032                $this->userOptionsManager->setOption( $user, $key, $value,
2033                    $except ? UserOptionsManager::GLOBAL_OVERRIDE : UserOptionsManager::GLOBAL_IGNORE );
2034            }
2035
2036            $this->hookRunner->onPreferencesFormPreSave(
2037                $formData, $form, $user, $result, $oldUserOptions );
2038        }
2039
2040        $user->saveSettings();
2041
2042        return $result;
2043    }
2044
2045    /**
2046     * Applies filters to preferences either before or after form usage
2047     *
2048     * @param array &$preferences
2049     * @param array $formDescriptor
2050     * @param string $verb Name of the filter method to call, either 'filterFromForm' or
2051     *         'filterForForm'
2052     */
2053    protected function applyFilters( array &$preferences, array $formDescriptor, $verb ) {
2054        foreach ( $formDescriptor as $preference => $desc ) {
2055            if ( !isset( $desc['filter'] ) || !isset( $preferences[$preference] ) ) {
2056                continue;
2057            }
2058            $filterDesc = $desc['filter'];
2059            if ( $filterDesc instanceof Filter ) {
2060                $filter = $filterDesc;
2061            } elseif ( class_exists( $filterDesc ) ) {
2062                $filter = new $filterDesc();
2063            } elseif ( is_callable( $filterDesc ) ) {
2064                $filter = $filterDesc();
2065            } else {
2066                throw new UnexpectedValueException(
2067                    "Unrecognized filter type for preference '$preference'"
2068                );
2069            }
2070            $preferences[$preference] = $filter->$verb( $preferences[$preference] );
2071        }
2072    }
2073
2074    /**
2075     * Save the form data and reload the page
2076     *
2077     * @param array $formData
2078     * @param PreferencesFormOOUI $form
2079     * @param array $formDescriptor
2080     * @return Status
2081     */
2082    protected function submitForm(
2083        array $formData,
2084        PreferencesFormOOUI $form,
2085        array $formDescriptor
2086    ) {
2087        $res = $this->saveFormData( $formData, $form, $formDescriptor );
2088
2089        if ( $res === true ) {
2090            $context = $form->getContext();
2091            $urlOptions = [];
2092
2093            $urlOptions += $form->getExtraSuccessRedirectParameters();
2094
2095            $url = $form->getTitle()->getFullURL( $urlOptions );
2096
2097            // Set session data for the success message
2098            $context->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 );
2099
2100            $context->getOutput()->redirect( $url );
2101        }
2102
2103        return ( $res === true ? Status::newGood() : $res );
2104    }
2105
2106    public function getResetKinds(
2107        User $user, IContextSource $context, $options = null
2108    ): array {
2109        $options ??= $this->userOptionsManager->loadUserOptions( $user );
2110
2111        $prefs = $this->getFormDescriptor( $user, $context );
2112        $mapping = [];
2113
2114        // Pull out the "special" options, so they don't get converted as
2115        // multiselect or checkmatrix.
2116        $specialOptions = array_fill_keys( $this->getSaveBlacklist(), true );
2117        foreach ( $specialOptions as $name => $value ) {
2118            unset( $prefs[$name] );
2119        }
2120
2121        // Multiselect and checkmatrix options are stored in the database with
2122        // one key per option, each having a boolean value. Extract those keys.
2123        $multiselectOptions = [];
2124        foreach ( $prefs as $name => $info ) {
2125            if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
2126                // Checking old alias for compatibility with unchanged extensions
2127                ( isset( $info['class'] ) && $info['class'] === \HTMLMultiSelectField::class ) ||
2128                ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
2129            ) {
2130                $opts = HTMLFormField::flattenOptions( $info['options'] ?? $info['options-messages'] );
2131                $prefix = $info['prefix'] ?? $name;
2132
2133                foreach ( $opts as $value ) {
2134                    $multiselectOptions["$prefix$value"] = true;
2135                }
2136
2137                unset( $prefs[$name] );
2138            }
2139        }
2140        $checkmatrixOptions = [];
2141        foreach ( $prefs as $name => $info ) {
2142            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
2143                // Checking old alias for compatibility with unchanged extensions
2144                ( isset( $info['class'] ) && $info['class'] === \HTMLCheckMatrix::class ) ||
2145                ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
2146            ) {
2147                $columns = HTMLFormField::flattenOptions( $info['columns'] );
2148                $rows = HTMLFormField::flattenOptions( $info['rows'] );
2149                $prefix = $info['prefix'] ?? $name;
2150
2151                foreach ( $columns as $column ) {
2152                    foreach ( $rows as $row ) {
2153                        $checkmatrixOptions["$prefix$column-$row"] = true;
2154                    }
2155                }
2156
2157                unset( $prefs[$name] );
2158            }
2159        }
2160
2161        // $value is ignored
2162        foreach ( $options as $key => $value ) {
2163            if ( isset( $prefs[$key] ) ) {
2164                $mapping[$key] = 'registered';
2165            } elseif ( isset( $multiselectOptions[$key] ) ) {
2166                $mapping[$key] = 'registered-multiselect';
2167            } elseif ( isset( $checkmatrixOptions[$key] ) ) {
2168                $mapping[$key] = 'registered-checkmatrix';
2169            } elseif ( isset( $specialOptions[$key] ) ) {
2170                $mapping[$key] = 'special';
2171            } elseif ( str_starts_with( $key, 'userjs-' ) ) {
2172                $mapping[$key] = 'userjs';
2173            } elseif ( str_starts_with( $key, UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX ) ) {
2174                $mapping[$key] = 'local-exception';
2175            } else {
2176                $mapping[$key] = 'unused';
2177            }
2178        }
2179
2180        return $mapping;
2181    }
2182
2183    public function listResetKinds() {
2184        return [
2185            'registered',
2186            'registered-multiselect',
2187            'registered-checkmatrix',
2188            'userjs',
2189            'special',
2190            'unused'
2191        ];
2192    }
2193
2194    public function getOptionNamesForReset( User $user, IContextSource $context, $kinds ) {
2195        $oldOptions = $this->userOptionsManager->loadUserOptions( $user, IDBAccessObject::READ_LATEST );
2196
2197        if ( !is_array( $kinds ) ) {
2198            $kinds = [ $kinds ];
2199        }
2200
2201        if ( in_array( 'all', $kinds ) ) {
2202            return array_keys( $oldOptions );
2203        } else {
2204            $optionKinds = $this->getResetKinds( $user, $context );
2205            $kinds = array_intersect( $kinds, $this->listResetKinds() );
2206            $optionNames = [];
2207
2208            foreach ( $oldOptions as $key => $value ) {
2209                if ( in_array( $optionKinds[$key], $kinds ) ) {
2210                    $optionNames[] = $key;
2211                }
2212            }
2213            return $optionNames;
2214        }
2215    }
2216}