Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.57% covered (danger)
37.57%
470 / 1251
6.45% covered (danger)
6.45%
2 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
DefaultPreferencesFactory
37.57% covered (danger)
37.57%
470 / 1251
6.45% covered (danger)
6.45%
2 / 31
15457.62
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.31% covered (warning)
80.31%
306 / 381
0.00% covered (danger)
0.00%
0 / 1
70.84
 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\Skin\SkinFactory;
52use MediaWiki\SpecialPage\SpecialPage;
53use MediaWiki\Specials\SpecialWatchlist;
54use MediaWiki\Status\Status;
55use MediaWiki\Title\NamespaceInfo;
56use MediaWiki\Title\Title;
57use MediaWiki\User\Options\UserOptionsLookup;
58use MediaWiki\User\Options\UserOptionsManager;
59use MediaWiki\User\User;
60use MediaWiki\User\UserGroupManager;
61use MediaWiki\User\UserGroupMembership;
62use MediaWiki\User\UserTimeCorrection;
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 UnexpectedValueException;
73use Wikimedia\Rdbms\IDBAccessObject;
74
75/**
76 * This is the default implementation of PreferencesFactory.
77 */
78class DefaultPreferencesFactory implements PreferencesFactory {
79    use LoggerAwareTrait;
80
81    /** @var ServiceOptions */
82    protected $options;
83
84    /** @var Language The wiki's content language. */
85    protected $contLang;
86
87    /** @var LanguageNameUtils */
88    protected $languageNameUtils;
89
90    /** @var AuthManager */
91    protected $authManager;
92
93    /** @var LinkRenderer */
94    protected $linkRenderer;
95
96    /** @var NamespaceInfo */
97    protected $nsInfo;
98
99    /** @var PermissionManager */
100    protected $permissionManager;
101
102    /** @var ILanguageConverter */
103    private $languageConverter;
104
105    /** @var HookRunner */
106    private $hookRunner;
107
108    /** @var UserOptionsManager */
109    protected $userOptionsManager;
110
111    /** @var LanguageConverterFactory */
112    private $languageConverterFactory;
113
114    /** @var ParserFactory */
115    private $parserFactory;
116
117    /** @var SkinFactory */
118    private $skinFactory;
119
120    /** @var UserGroupManager */
121    private $userGroupManager;
122
123    /** @var SignatureValidatorFactory */
124    private $signatureValidatorFactory;
125
126    /**
127     * @internal For use by ServiceWiring
128     */
129    public const CONSTRUCTOR_OPTIONS = [
130        MainConfigNames::AllowUserCss,
131        MainConfigNames::AllowUserCssPrefs,
132        MainConfigNames::AllowUserJs,
133        MainConfigNames::DefaultSkin,
134        MainConfigNames::EmailAuthentication,
135        MainConfigNames::EmailConfirmToEdit,
136        MainConfigNames::EnableEditRecovery,
137        MainConfigNames::EnableEmail,
138        MainConfigNames::EnableUserEmail,
139        MainConfigNames::EnableUserEmailMuteList,
140        MainConfigNames::EnotifMinorEdits,
141        MainConfigNames::EnotifRevealEditorAddress,
142        MainConfigNames::EnotifUserTalk,
143        MainConfigNames::EnotifWatchlist,
144        MainConfigNames::ForceHTTPS,
145        MainConfigNames::HiddenPrefs,
146        MainConfigNames::ImageLimits,
147        MainConfigNames::LanguageCode,
148        MainConfigNames::LocalTZoffset,
149        MainConfigNames::MaxSigChars,
150        MainConfigNames::RCMaxAge,
151        MainConfigNames::RCShowWatchingUsers,
152        MainConfigNames::RCWatchCategoryMembership,
153        MainConfigNames::SearchMatchRedirectPreference,
154        MainConfigNames::SecureLogin,
155        MainConfigNames::ScriptPath,
156        MainConfigNames::SignatureValidation,
157        MainConfigNames::SkinsPreferred,
158        MainConfigNames::ThumbLimits,
159        MainConfigNames::ThumbnailNamespaces,
160    ];
161
162    /**
163     * @param ServiceOptions $options
164     * @param Language $contLang
165     * @param AuthManager $authManager
166     * @param LinkRenderer $linkRenderer
167     * @param NamespaceInfo $nsInfo
168     * @param PermissionManager $permissionManager
169     * @param ILanguageConverter $languageConverter
170     * @param LanguageNameUtils $languageNameUtils
171     * @param HookContainer $hookContainer
172     * @param UserOptionsLookup $userOptionsLookup Should be an instance of UserOptionsManager
173     * @param LanguageConverterFactory|null $languageConverterFactory
174     * @param ParserFactory|null $parserFactory
175     * @param SkinFactory|null $skinFactory
176     * @param UserGroupManager|null $userGroupManager
177     * @param SignatureValidatorFactory|null $signatureValidatorFactory
178     */
179    public function __construct(
180        ServiceOptions $options,
181        Language $contLang,
182        AuthManager $authManager,
183        LinkRenderer $linkRenderer,
184        NamespaceInfo $nsInfo,
185        PermissionManager $permissionManager,
186        ILanguageConverter $languageConverter,
187        LanguageNameUtils $languageNameUtils,
188        HookContainer $hookContainer,
189        UserOptionsLookup $userOptionsLookup,
190        ?LanguageConverterFactory $languageConverterFactory = null,
191        ?ParserFactory $parserFactory = null,
192        ?SkinFactory $skinFactory = null,
193        ?UserGroupManager $userGroupManager = null,
194        ?SignatureValidatorFactory $signatureValidatorFactory = null
195    ) {
196        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
197
198        $this->options = $options;
199        $this->contLang = $contLang;
200        $this->authManager = $authManager;
201        $this->linkRenderer = $linkRenderer;
202        $this->nsInfo = $nsInfo;
203
204        // We don't use the PermissionManager anymore, but we need to be careful
205        // removing the parameter since this class is extended by GlobalPreferencesFactory
206        // in the GlobalPreferences extension, and that class uses it
207        $this->permissionManager = $permissionManager;
208
209        $this->logger = new NullLogger();
210        $this->languageConverter = $languageConverter;
211        $this->languageNameUtils = $languageNameUtils;
212        $this->hookRunner = new HookRunner( $hookContainer );
213
214        // Don't break GlobalPreferences, fall back to global state if missing services
215        // or if passed a UserOptionsLookup that isn't UserOptionsManager
216        $services = static function () {
217            // BC hack. Use a closure so this can be unit-tested.
218            return MediaWikiServices::getInstance();
219        };
220        $this->userOptionsManager = ( $userOptionsLookup instanceof UserOptionsManager )
221            ? $userOptionsLookup
222            : $services()->getUserOptionsManager();
223        $this->languageConverterFactory = $languageConverterFactory ?? $services()->getLanguageConverterFactory();
224
225        $this->parserFactory = $parserFactory ?? $services()->getParserFactory();
226        $this->skinFactory = $skinFactory ?? $services()->getSkinFactory();
227        $this->userGroupManager = $userGroupManager ?? $services()->getUserGroupManager();
228        $this->signatureValidatorFactory = $signatureValidatorFactory
229            ?? $services()->getSignatureValidatorFactory();
230    }
231
232    /**
233     * @inheritDoc
234     */
235    public function getSaveBlacklist() {
236        return [
237            'realname',
238            'emailaddress',
239        ];
240    }
241
242    /**
243     * @param User $user
244     * @param IContextSource $context
245     * @return array
246     */
247    public function getFormDescriptor( User $user, IContextSource $context ) {
248        $preferences = [];
249
250        OutputPage::setupOOUI(
251            strtolower( $context->getSkin()->getSkinName() ),
252            $context->getLanguage()->getDir()
253        );
254
255        $this->profilePreferences( $user, $context, $preferences );
256        $this->skinPreferences( $user, $context, $preferences );
257        $this->datetimePreferences( $user, $context, $preferences );
258        $this->filesPreferences( $context, $preferences );
259        $this->renderingPreferences( $user, $context, $preferences );
260        $this->editingPreferences( $user, $context, $preferences );
261        $this->rcPreferences( $user, $context, $preferences );
262        $this->watchlistPreferences( $user, $context, $preferences );
263        $this->searchPreferences( $context, $preferences );
264
265        $this->hookRunner->onGetPreferences( $user, $preferences );
266
267        $this->loadPreferenceValues( $user, $context, $preferences );
268        $this->logger->debug( "Created form descriptor for user '{$user->getName()}'" );
269        return $preferences;
270    }
271
272    /**
273     * Simplify form descriptor for validation or something similar.
274     *
275     * @param array $descriptor HTML form descriptor.
276     * @return array
277     */
278    public static function simplifyFormDescriptor( array $descriptor ) {
279        foreach ( $descriptor as $name => &$params ) {
280            // Info fields are useless and can use complicated closure to provide
281            // text, skip all of them.
282            if ( ( isset( $params['type'] ) && $params['type'] === 'info' ) ||
283                // Checking old alias for compatibility with unchanged extensions
284                ( isset( $params['class'] ) && $params['class'] === \HTMLInfoField::class ) ||
285                ( isset( $params['class'] ) && $params['class'] === HTMLInfoField::class )
286            ) {
287                unset( $descriptor[$name] );
288                continue;
289            }
290            // Message parsing is the heaviest load when constructing the field,
291            // but we just want to validate data.
292            foreach ( $params as $key => $value ) {
293                switch ( $key ) {
294                    // Special case, should be kept.
295                    case 'options-message':
296                        break;
297                    // Special case, should be transferred.
298                    case 'options-messages':
299                        unset( $params[$key] );
300                        $params['options'] = $value;
301                        break;
302                    default:
303                        if ( preg_match( '/-messages?$/', $key ) ) {
304                            // Unwanted.
305                            unset( $params[$key] );
306                        }
307                }
308            }
309        }
310        return $descriptor;
311    }
312
313    /**
314     * Loads existing values for a given array of preferences
315     * @param User $user
316     * @param IContextSource $context
317     * @param array &$defaultPreferences Array to load values for
318     * @return array|null
319     */
320    private function loadPreferenceValues( User $user, IContextSource $context, &$defaultPreferences ) {
321        // Remove preferences that wikis don't want to use
322        foreach ( $this->options->get( MainConfigNames::HiddenPrefs ) as $pref ) {
323            unset( $defaultPreferences[$pref] );
324        }
325
326        // For validation.
327        $simplified = self::simplifyFormDescriptor( $defaultPreferences );
328        $form = new HTMLForm( $simplified, $context );
329
330        $disable = !$user->isAllowed( 'editmyoptions' );
331
332        $defaultOptions = $this->userOptionsManager->getDefaultOptions( $user );
333        $userOptions = $this->userOptionsManager->getOptions( $user );
334        $this->applyFilters( $userOptions, $defaultPreferences, 'filterForForm' );
335        // Add in defaults from the user
336        foreach ( $simplified as $name => $_ ) {
337            $info = &$defaultPreferences[$name];
338            if ( $disable && !in_array( $name, $this->getSaveBlacklist() ) ) {
339                $info['disabled'] = 'disabled';
340            }
341            if ( isset( $info['default'] ) ) {
342                // Already set, no problem
343                continue;
344            }
345            $field = $form->getField( $name );
346            $globalDefault = $defaultOptions[$name] ?? null;
347            $prefFromUser = static::getPreferenceForField( $name, $field, $userOptions );
348
349            // If it validates, set it as the default
350            // FIXME: That's not how the validate() function works! Values of nested fields
351            // (e.g. CheckMatix) would be missing.
352            if ( $prefFromUser !== null && // Make sure we're not just pulling nothing
353                    $field->validate( $prefFromUser, $this->userOptionsManager->getOptions( $user ) ) === true ) {
354                $info['default'] = $prefFromUser;
355            } elseif ( $field->validate( $globalDefault, $this->userOptionsManager->getOptions( $user ) ) === true ) {
356                $info['default'] = $globalDefault;
357            } else {
358                $globalDefault = json_encode( $globalDefault );
359                throw new UnexpectedValueException(
360                    "Default '$globalDefault' is invalid for preference $name of user " . $user->getName()
361                );
362            }
363        }
364
365        return $defaultPreferences;
366    }
367
368    /**
369     * Get preference values for the 'default' param of html form descriptor, compatible
370     * with nested fields.
371     *
372     * @since 1.41
373     * @param string $name
374     * @param HTMLFormField $field
375     * @param array $userOptions
376     * @return array|string
377     */
378    public static function getPreferenceForField( $name, HTMLFormField $field, array $userOptions ) {
379        $val = $userOptions[$name] ?? null;
380
381        if ( $field instanceof HTMLNestedFilterable ) {
382            $val = [];
383            $prefix = $field->mParams['prefix'] ?? $name;
384            // Fetch all possible preference keys of the given field on this wiki.
385            $keys = array_keys( $field->filterDataForSubmit( [] ) );
386            foreach ( $keys as $key ) {
387                if ( $userOptions[$prefix . $key] ?? false ) {
388                    $val[] = $key;
389                }
390            }
391        }
392
393        return $val;
394    }
395
396    /**
397     * Pull option from a user account. Handles stuff like array-type preferences.
398     *
399     * @deprecated since 1.41; Use getPreferenceForField() instead.
400     * @param string $name
401     * @param array $info
402     * @param array $userOptions
403     * @return array|string
404     */
405    protected function getOptionFromUser( $name, $info, array $userOptions ) {
406        $val = $userOptions[$name] ?? null;
407
408        // Handling for multiselect preferences
409        if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
410            // Checking old alias for compatibility with unchanged extensions
411            ( isset( $info['class'] ) && $info['class'] === \HTMLMultiSelectField::class ) ||
412            ( isset( $info['class'] ) && $info['class'] === HTMLMultiSelectField::class )
413        ) {
414            $options = HTMLFormField::flattenOptions( $info['options-messages'] ?? $info['options'] );
415            $prefix = $info['prefix'] ?? $name;
416            $val = [];
417
418            foreach ( $options as $value ) {
419                if ( $userOptions["$prefix$value"] ?? false ) {
420                    $val[] = $value;
421                }
422            }
423        }
424
425        // Handling for checkmatrix preferences
426        if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
427            // Checking old alias for compatibility with unchanged extensions
428            ( isset( $info['class'] ) && $info['class'] === \HTMLCheckMatrix::class ) ||
429            ( isset( $info['class'] ) && $info['class'] === HTMLCheckMatrix::class )
430        ) {
431            $columns = HTMLFormField::flattenOptions( $info['columns'] );
432            $rows = HTMLFormField::flattenOptions( $info['rows'] );
433            $prefix = $info['prefix'] ?? $name;
434            $val = [];
435
436            foreach ( $columns as $column ) {
437                foreach ( $rows as $row ) {
438                    if ( $userOptions["$prefix$column-$row"] ?? false ) {
439                        $val[] = "$column-$row";
440                    }
441                }
442            }
443        }
444
445        return $val;
446    }
447
448    /**
449     * @todo Inject user Language instead of using context.
450     * @param User $user
451     * @param IContextSource $context
452     * @param array &$defaultPreferences
453     * @return void
454     */
455    protected function profilePreferences(
456        User $user, IContextSource $context, &$defaultPreferences
457    ) {
458        // retrieving user name for GENDER and misc.
459        $userName = $user->getName();
460
461        // Information panel
462        $defaultPreferences['username'] = [
463            'type' => 'info',
464            'label-message' => [ 'username', $userName ],
465            'default' => $userName,
466            'section' => 'personal/info',
467        ];
468
469        $lang = $context->getLanguage();
470
471        // Get groups to which the user belongs, Skip the default * group, seems useless here
472        $userEffectiveGroups = array_diff(
473            $this->userGroupManager->getUserEffectiveGroups( $user ),
474            [ '*' ]
475        );
476        $defaultPreferences['usergroups'] = [
477            'type' => 'info',
478            'label-message' => [ 'prefs-memberingroups',
479                Message::numParam( count( $userEffectiveGroups ) ), $userName ],
480            'default' => function () use ( $user, $userEffectiveGroups, $context, $lang, $userName ) {
481                $userGroupMemberships = $this->userGroupManager->getUserGroupMemberships( $user );
482                $userGroups = $userMembers = $userTempGroups = $userTempMembers = [];
483                foreach ( $userEffectiveGroups as $ueg ) {
484                    $groupStringOrObject = $userGroupMemberships[$ueg] ?? $ueg;
485
486                    $userG = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context );
487                    $userM = UserGroupMembership::getLinkHTML( $groupStringOrObject, $context, $userName );
488
489                    // Store expiring groups separately, so we can place them before non-expiring
490                    // groups in the list. This is to avoid the ambiguity of something like
491                    // "administrator, bureaucrat (until X date)" -- users might wonder whether the
492                    // expiry date applies to both groups, or just the last one
493                    if ( $groupStringOrObject instanceof UserGroupMembership &&
494                        $groupStringOrObject->getExpiry()
495                    ) {
496                        $userTempGroups[] = $userG;
497                        $userTempMembers[] = $userM;
498                    } else {
499                        $userGroups[] = $userG;
500                        $userMembers[] = $userM;
501                    }
502                }
503                sort( $userGroups );
504                sort( $userMembers );
505                sort( $userTempGroups );
506                sort( $userTempMembers );
507                $userGroups = array_merge( $userTempGroups, $userGroups );
508                $userMembers = array_merge( $userTempMembers, $userMembers );
509                return $context->msg( 'prefs-memberingroups-type' )
510                    ->rawParams( $lang->commaList( $userGroups ), $lang->commaList( $userMembers ) )
511                    ->escaped();
512            },
513            'raw' => true,
514            'section' => 'personal/info',
515        ];
516
517        $contribTitle = SpecialPage::getTitleFor( "Contributions", $userName );
518        $formattedEditCount = $lang->formatNum( $user->getEditCount() );
519        $editCount = $this->linkRenderer->makeLink( $contribTitle, $formattedEditCount );
520
521        $defaultPreferences['editcount'] = [
522            'type' => 'info',
523            'raw' => true,
524            'label-message' => 'prefs-edits',
525            'default' => $editCount,
526            'section' => 'personal/info',
527        ];
528
529        if ( $user->getRegistration() ) {
530            $displayUser = $context->getUser();
531            $userRegistration = $user->getRegistration();
532            $defaultPreferences['registrationdate'] = [
533                'type' => 'info',
534                'label-message' => 'prefs-registration',
535                'default' => $context->msg(
536                    'prefs-registration-date-time',
537                    $lang->userTimeAndDate( $userRegistration, $displayUser ),
538                    $lang->userDate( $userRegistration, $displayUser ),
539                    $lang->userTime( $userRegistration, $displayUser )
540                )->text(),
541                'section' => 'personal/info',
542            ];
543        }
544
545        $canViewPrivateInfo = $user->isAllowed( 'viewmyprivateinfo' );
546        $canEditPrivateInfo = $user->isAllowed( 'editmyprivateinfo' );
547
548        // Actually changeable stuff
549        $defaultPreferences['realname'] = [
550            // (not really "private", but still shouldn't be edited without permission)
551            'type' => $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'realname' )
552                ? 'text' : 'info',
553            'default' => $user->getRealName(),
554            'section' => 'personal/info',
555            'label-message' => 'yourrealname',
556            'help-message' => 'prefs-help-realname',
557        ];
558
559        if ( $canEditPrivateInfo && $this->authManager->allowsAuthenticationDataChange(
560            new PasswordAuthenticationRequest(), false )->isGood()
561        ) {
562            $defaultPreferences['password'] = [
563                'type' => 'info',
564                'raw' => true,
565                'default' => (string)new ButtonWidget( [
566                    'href' => SpecialPage::getTitleFor( 'ChangePassword' )->getLinkURL( [
567                        'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
568                    ] ),
569                    'label' => $context->msg( 'prefs-resetpass' )->text(),
570                ] ),
571                'label-message' => 'yourpassword',
572                // email password reset feature only works for users that have an email set up
573                'help-raw' => $user->getEmail()
574                    ? $context->msg( 'prefs-help-yourpassword',
575                        '[[#mw-prefsection-personal-email|{{int:prefs-email}}]]' )->parse()
576                    : '',
577                'section' => 'personal/info',
578            ];
579        }
580        // Only show prefershttps if secure login is turned on
581        if ( !$this->options->get( MainConfigNames::ForceHTTPS )
582            && $this->options->get( MainConfigNames::SecureLogin )
583        ) {
584            $defaultPreferences['prefershttps'] = [
585                'type' => 'toggle',
586                'label-message' => 'tog-prefershttps',
587                'help-message' => 'prefs-help-prefershttps',
588                'section' => 'personal/info'
589            ];
590        }
591
592        $defaultPreferences['downloaduserdata'] = [
593            'type' => 'info',
594            'raw' => true,
595            'label-message' => 'prefs-user-downloaddata-label',
596            'default' => Html::element(
597                'a',
598                [
599                    'href' => $this->options->get( MainConfigNames::ScriptPath ) .
600                        '/api.php?action=query&meta=userinfo&uiprop=*&formatversion=2',
601                ],
602                $context->msg( 'prefs-user-downloaddata-info' )->text()
603            ),
604            'help-message' => [ 'prefs-user-downloaddata-help-message', urlencode( $user->getTitleKey() ) ],
605            'section' => 'personal/info',
606        ];
607
608        $defaultPreferences['restoreprefs'] = [
609            'type' => 'info',
610            'raw' => true,
611            'label-message' => 'prefs-user-restoreprefs-label',
612            'default' => Html::element(
613                'a',
614                [
615                    'href' => SpecialPage::getTitleFor( 'Preferences' )
616                        ->getSubpage( 'reset' )->getLocalURL()
617                ],
618                $context->msg( 'prefs-user-restoreprefs-info' )->text()
619            ),
620            'section' => 'personal/info',
621        ];
622
623        $languages = $this->languageNameUtils->getLanguageNames(
624            LanguageNameUtils::AUTONYMS,
625            LanguageNameUtils::SUPPORTED
626        );
627        $languageCode = $this->options->get( MainConfigNames::LanguageCode );
628        if ( !array_key_exists( $languageCode, $languages ) ) {
629            $languages[$languageCode] = $languageCode;
630            // Sort the array again
631            ksort( $languages );
632        }
633
634        $options = [];
635        foreach ( $languages as $code => $name ) {
636            $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
637            $options[$display] = $code;
638        }
639        $defaultPreferences['language'] = [
640            'type' => 'select',
641            'section' => 'personal/i18n',
642            'options' => $options,
643            'label-message' => 'yourlanguage',
644        ];
645
646        $neutralGenderMessage = $context->msg( 'gender-notknown' )->escaped() . (
647            !$context->msg( 'gender-unknown' )->isDisabled()
648                ? "<br>" . $context->msg( 'parentheses' )
649                    ->params( $context->msg( 'gender-unknown' )->plain() )
650                    ->escaped()
651                : ''
652        );
653
654        $defaultPreferences['gender'] = [
655            'type' => 'radio',
656            'section' => 'personal/i18n',
657            'options' => [
658                $neutralGenderMessage => 'unknown',
659                $context->msg( 'gender-female' )->escaped() => 'female',
660                $context->msg( 'gender-male' )->escaped() => 'male',
661            ],
662            'label-message' => 'yourgender',
663            'help-message' => 'prefs-help-gender',
664        ];
665
666        // see if there are multiple language variants to choose from
667        if ( !$this->languageConverterFactory->isConversionDisabled() ) {
668
669            foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
670                if ( $langCode == $this->contLang->getCode() ) {
671                    if ( !$this->languageConverter->hasVariants() ) {
672                        continue;
673                    }
674
675                    $variants = $this->languageConverter->getVariants();
676                    $variantArray = [];
677                    foreach ( $variants as $v ) {
678                        $v = str_replace( '_', '-', strtolower( $v ) );
679                        $variantArray[$v] = $lang->getVariantname( $v, false );
680                    }
681
682                    $options = [];
683                    foreach ( $variantArray as $code => $name ) {
684                        $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
685                        $options[$display] = $code;
686                    }
687
688                    $defaultPreferences['variant'] = [
689                        'label-message' => 'yourvariant',
690                        'type' => 'select',
691                        'options' => $options,
692                        'section' => 'personal/i18n',
693                        'help-message' => 'prefs-help-variant',
694                    ];
695                } else {
696                    $defaultPreferences["variant-$langCode"] = [
697                        'type' => 'api',
698                    ];
699                }
700            }
701        }
702
703        // show a preview of the old signature first
704        $oldsigWikiText = $this->parserFactory->getInstance()->preSaveTransform(
705            '~~~',
706            $context->getTitle(),
707            $user,
708            ParserOptions::newFromContext( $context )
709        );
710        $oldsigHTML = Parser::stripOuterParagraph(
711            $context->getOutput()->parseAsContent( $oldsigWikiText )
712        );
713        $signatureFieldConfig = [];
714        // Validate existing signature and show a message about it
715        $signature = $this->userOptionsManager->getOption( $user, 'nickname' );
716        $useFancySig = $this->userOptionsManager->getBoolOption( $user, 'fancysig' );
717        if ( $useFancySig && $signature !== '' ) {
718            $parserOpts = ParserOptions::newFromContext( $context );
719            $validator = $this->signatureValidatorFactory
720                ->newSignatureValidator( $user, $context, $parserOpts );
721            $signatureErrors = $validator->validateSignature( $signature );
722            if ( $signatureErrors ) {
723                $sigValidation = $this->options->get( MainConfigNames::SignatureValidation );
724                $oldsigHTML .= '<p><strong>' .
725                    // Messages used here:
726                    // * prefs-signature-invalid-warning
727                    // * prefs-signature-invalid-new
728                    // * prefs-signature-invalid-disallow
729                    $context->msg( "prefs-signature-invalid-$sigValidation" )->parse() .
730                    '</strong></p>';
731
732                // On initial page load, show the warnings as well
733                // (when posting, you get normal validation errors instead)
734                foreach ( $signatureErrors as &$sigError ) {
735                    $sigError = new HtmlSnippet( $sigError );
736                }
737                if ( !$context->getRequest()->wasPosted() ) {
738                    $signatureFieldConfig = [
739                        'warnings' => $sigValidation !== 'disallow' ? $signatureErrors : null,
740                        'errors' => $sigValidation === 'disallow' ? $signatureErrors : null,
741                    ];
742                }
743            }
744        }
745        $defaultPreferences['oldsig'] = [
746            'type' => 'info',
747            // Normally HTMLFormFields do not display warnings, so we need to use 'rawrow'
748            // and provide the entire OOUI\FieldLayout here
749            'rawrow' => true,
750            'default' => new FieldLayout(
751                new LabelWidget( [
752                    'label' => new HtmlSnippet( $oldsigHTML ),
753                ] ),
754                [
755                    'align' => 'top',
756                    'label' => new HtmlSnippet( $context->msg( 'tog-oldsig' )->parse() )
757                ] + $signatureFieldConfig
758            ),
759            'section' => 'personal/signature',
760        ];
761        $defaultPreferences['nickname'] = [
762            'type' => $this->authManager->allowsPropertyChange( 'nickname' ) ? 'text' : 'info',
763            'maxlength' => $this->options->get( MainConfigNames::MaxSigChars ),
764            'label-message' => 'yournick',
765            'validation-callback' => function ( $signature, $alldata, HTMLForm $form ) {
766                return $this->validateSignature( $signature, $alldata, $form );
767            },
768            'section' => 'personal/signature',
769            'filter-callback' => function ( $signature, array $alldata, HTMLForm $form ) {
770                return $this->cleanSignature( $signature, $alldata, $form );
771            },
772        ];
773        $defaultPreferences['fancysig'] = [
774            'type' => 'toggle',
775            'label-message' => 'tog-fancysig',
776            // show general help about signature at the bottom of the section
777            'help-message' => 'prefs-help-signature',
778            'section' => 'personal/signature'
779        ];
780
781        // Email preferences
782        if ( $this->options->get( MainConfigNames::EnableEmail ) ) {
783            if ( $canViewPrivateInfo ) {
784                $helpMessages = [];
785                $helpMessages[] = $this->options->get( MainConfigNames::EmailConfirmToEdit )
786                        ? 'prefs-help-email-required'
787                        : 'prefs-help-email';
788
789                if ( $this->options->get( MainConfigNames::EnableUserEmail ) ) {
790                    // additional messages when users can send email to each other
791                    $helpMessages[] = 'prefs-help-email-others';
792                }
793
794                $emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
795                if ( $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'emailaddress' ) ) {
796                    $button = new ButtonWidget( [
797                        'href' => SpecialPage::getTitleFor( 'ChangeEmail' )->getLinkURL( [
798                            'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
799                        ] ),
800                        'label' =>
801                            $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
802                    ] );
803
804                    $emailAddress .= $emailAddress == '' ? $button : ( '<br />' . $button );
805                }
806
807                $defaultPreferences['emailaddress'] = [
808                    'type' => 'info',
809                    'raw' => true,
810                    'default' => $emailAddress,
811                    'label-message' => 'youremail',
812                    'section' => 'personal/email',
813                    'help-messages' => $helpMessages,
814                    // 'cssclass' chosen below
815                ];
816            }
817
818            $disableEmailPrefs = false;
819
820            $defaultPreferences['requireemail'] = [
821                'type' => 'toggle',
822                'label-message' => 'tog-requireemail',
823                'help-message' => 'prefs-help-requireemail',
824                'section' => 'personal/email',
825                'disabled' => !$user->getEmail(),
826            ];
827
828            if ( $this->options->get( MainConfigNames::EmailAuthentication ) ) {
829                if ( $user->getEmail() ) {
830                    if ( $user->getEmailAuthenticationTimestamp() ) {
831                        // date and time are separate parameters to facilitate localisation.
832                        // $time is kept for backward compat reasons.
833                        // 'emailauthenticated' is also used in SpecialConfirmemail.php
834                        $displayUser = $context->getUser();
835                        $emailTimestamp = $user->getEmailAuthenticationTimestamp();
836                        $time = $lang->userTimeAndDate( $emailTimestamp, $displayUser );
837                        $d = $lang->userDate( $emailTimestamp, $displayUser );
838                        $t = $lang->userTime( $emailTimestamp, $displayUser );
839                        $emailauthenticated = $context->msg( 'emailauthenticated',
840                            $time, $d, $t )->parse() . '<br />';
841                        $emailauthenticationclass = 'mw-email-authenticated';
842                    } else {
843                        $disableEmailPrefs = true;
844                        $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '<br />' .
845                            new ButtonWidget( [
846                                'href' => SpecialPage::getTitleFor( 'Confirmemail' )->getLinkURL(),
847                                'label' => $context->msg( 'emailconfirmlink' )->text(),
848                            ] );
849                        $emailauthenticationclass = "mw-email-not-authenticated";
850                    }
851                } else {
852                    $disableEmailPrefs = true;
853                    $emailauthenticated = $context->msg( 'noemailprefs' )->escaped();
854                    $emailauthenticationclass = 'mw-email-none';
855                }
856
857                if ( $canViewPrivateInfo ) {
858                    $defaultPreferences['emailauthentication'] = [
859                        'type' => 'info',
860                        'raw' => true,
861                        'section' => 'personal/email',
862                        'label-message' => 'prefs-emailconfirm-label',
863                        'default' => $emailauthenticated,
864                        // Apply the same CSS class used on the input to the message:
865                        'cssclass' => $emailauthenticationclass,
866                    ];
867                }
868            }
869
870            if ( $this->options->get( MainConfigNames::EnableUserEmail ) &&
871                $user->isAllowed( 'sendemail' )
872            ) {
873                $defaultPreferences['disablemail'] = [
874                    'id' => 'wpAllowEmail',
875                    'type' => 'toggle',
876                    'invert' => true,
877                    'section' => 'personal/email',
878                    'label-message' => 'allowemail',
879                    'disabled' => $disableEmailPrefs,
880                ];
881
882                $defaultPreferences['email-allow-new-users'] = [
883                    'id' => 'wpAllowEmailFromNewUsers',
884                    'type' => 'toggle',
885                    'section' => 'personal/email',
886                    'label-message' => 'email-allow-new-users-label',
887                    'help-message' => 'prefs-help-email-allow-new-users',
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 = Html::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}