jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/370152 )
Change subject: Switch to using PreferencesFactory, and improve UI ...................................................................... Switch to using PreferencesFactory, and improve UI This refactors the extension to use the new PreferencesFactory service and moves each preference's 'enable this globally?' toggle to the side of the preferences form to make it easier to see what's enabled and what's not. It also introduces a 'select-all' toggle for each tab of the preferences form. Bug: T173476 Bug: T68869 Change-Id: I3c10dfeacf02367e90f84a3e572ecf3f4048e02a --- M .stylelintrc.json M extension.json M i18n/en.json M i18n/qqq.json D includes/GlobalPreferences.php A includes/GlobalPreferencesFactory.php A includes/GlobalPreferencesForm.php M includes/Hooks.php M includes/SpecialGlobalPreferences.php A includes/Storage.php M resources/ext.GlobalPreferences.special.css M resources/ext.GlobalPreferences.special.js M resources/ext.GlobalPreferences.special.nojs.css A tests/phpunit/GlobalPreferencesTest.php 14 files changed, 781 insertions(+), 363 deletions(-) Approvals: MaxSem: Looks good to me, approved jenkins-bot: Verified diff --git a/.stylelintrc.json b/.stylelintrc.json index 2c90730..d691e9d 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,3 +1,6 @@ { - "extends": "stylelint-config-wikimedia" + "extends": "stylelint-config-wikimedia", + "rules": { + "selector-no-id": null + } } diff --git a/extension.json b/extension.json index 8245339..4824a25 100644 --- a/extension.json +++ b/extension.json @@ -23,9 +23,11 @@ "GlobalPreferencesAlias": "GlobalPreferences.alias.php" }, "AutoloadClasses": { - "GlobalPreferences\\GlobalPreferences": "includes/GlobalPreferences.php", "GlobalPreferences\\Hooks": "includes/Hooks.php", - "GlobalPreferences\\SpecialGlobalPreferences": "includes/SpecialGlobalPreferences.php" + "GlobalPreferences\\SpecialGlobalPreferences": "includes/SpecialGlobalPreferences.php", + "GlobalPreferences\\GlobalPreferencesFactory": "includes/GlobalPreferencesFactory.php", + "GlobalPreferences\\GlobalPreferencesForm": "includes/GlobalPreferencesForm.php", + "GlobalPreferences\\Storage": "includes/Storage.php" }, "Hooks": { "UserLoadOptions": [ @@ -39,11 +41,11 @@ ], "LoadExtensionSchemaUpdates": [ "GlobalPreferences\\Hooks::onLoadExtensionSchemaUpdates" + ], + "MediaWikiServices": [ + "GlobalPreferences\\Hooks::onMediaWikiServices" ] }, - "ExtensionFunctions": [ - "GlobalPreferences\\Hooks::onExtensionFunctions" - ], "ResourceFileModulePaths": { "localBasePath": "resources", "remoteExtPath": "GlobalPreferences/resources" @@ -51,7 +53,8 @@ "ResourceModules": { "ext.GlobalPreferences.special": { "styles": "ext.GlobalPreferences.special.css", - "scripts": "ext.GlobalPreferences.special.js" + "scripts": "ext.GlobalPreferences.special.js", + "messages": [ "globalprefs-select-all" ] }, "ext.GlobalPreferences.special.nojs": { "styles": "ext.GlobalPreferences.special.nojs.css" diff --git a/i18n/en.json b/i18n/en.json index 0e37692..119902e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -5,13 +5,14 @@ ] }, "globalprefs-desc": "Allows users to set global preferences", - "globalprefs-set-globally": "This preference has been set globally and must be modified through [[Special:GlobalPreferences|your global preferences]].", - "globalprefs-check-label": "Use this preference on all wikis", + "globalprefs-set-globally": "This preference has been set globally and must be modified through [[Special:GlobalPreferences#$1|your global preferences]].", + "tooltip-globalprefs-check-label": "Make this setting global", "globalprefs-error-header": "Error", "globalprefs-notglobal": "Your account is not a global account and cannot set global preferences.", "globalpreferences": "Global preferences", "globalprefs-info-label": "Global preferences:", "globalprefs-info-link": "Set your global preferences", "globalprefs-reset-intro": "You can use this page to disable all global preferences and return to your local preferences.\nThis cannot be undone.", - "globalprefs-restoreprefs": "Remove all global preferences (in all sections)" + "globalprefs-restoreprefs": "Remove all global preferences (in all sections)", + "globalprefs-select-all": "Select all" } diff --git a/i18n/qqq.json b/i18n/qqq.json index f158572..d2c1323 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -7,12 +7,13 @@ }, "globalprefs-desc": "{{desc|name=Global Preferences|url=https://www.mediawiki.org/wiki/Extension:GlobalPreferences}}", "globalprefs-set-globally": "Help message below a disabled preference option instructing the user to change it at their global preferences page.", - "globalprefs-check-label": "Label for a checkbox that enables the user to save that (the above) preference globally.", + "tooltip-globalprefs-check-label": "The tooltip and the label text for the checkbox to enable a preference globally.", "globalprefs-error-header": "Page title for error message.\n{{Identical|Error}}", - "globalprefs-notglobal": "Error message a user sees if they don not have a global account.", + "globalprefs-notglobal": "Error message a user sees if they do not have a global account.", "globalpreferences": "{{doc-special|GlobalPreferences}}", "globalprefs-info-label": "Label for link in [[Special:Preferences]] to go set your global preferences.", "globalprefs-info-link": "Link text to [[Special:GlobalPreferences]].", "globalprefs-reset-intro": "Used in [[Special:GlobalPreferences/reset]].", - "globalprefs-restoreprefs": "Used as link text in [[Special:GlobalPreferences]]. The link points to [[Special:GlobalPreferences/reset]] which shows the \"Remove all global preferences\" form.\n\nAlso used as label for the Submit button in [[Special:GlobalPreferences/reset]]." + "globalprefs-restoreprefs": "Used as link text in [[Special:GlobalPreferences]]. The link points to [[Special:GlobalPreferences/reset]] which shows the \"Remove all global preferences\" form.\n\nAlso used as label for the Submit button in [[Special:GlobalPreferences/reset]].", + "globalprefs-select-all": "Label for the checkbox for selecting all of a section's preferences." } diff --git a/includes/GlobalPreferences.php b/includes/GlobalPreferences.php deleted file mode 100644 index 351f7f5..0000000 --- a/includes/GlobalPreferences.php +++ /dev/null @@ -1,103 +0,0 @@ -<?php -/** - * Implements global preferences for MediaWiki - * - * @author Kunal Mehta <lego...@gmail.com> - * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later - * @file - * @ingroup Extensions - * - * Partially based off of work by Werdna - * https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790 - */ - -namespace GlobalPreferences; - -use CentralIdLookup; -use IContextSource; -use RequestContext; -use User; -use Wikimedia\Rdbms\Database; - -class GlobalPreferences { - - /** - * @param int $type one of the DB_* constants - * @return Database - */ - public static function getPrefsDB( $type = DB_REPLICA ) { - global $wgGlobalPreferencesDB; - if ( $wgGlobalPreferencesDB ) { - return wfGetDB( $type, [], $wgGlobalPreferencesDB ); - } else { - return wfGetDB( $type ); - } - } - - /** - * Checks if the user is globalized - * @param User $user The user - * @return bool - */ - public static function isUserGlobalized( User $user ) { - if ( $user->isAnon() ) { - // No prefs for anons, sorry :( - return false; - } - - return self::getUserID( $user ) !== 0; - } - - /** - * Gets the user's ID that we're using in the table - * Returns 0 if the user is not global - * @param User $user The user for whom to get the ID. - * @return int - */ - public static function getUserID( User $user ) { - $lookup = CentralIdLookup::factory(); - return $lookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW ); - } - - /** - * Deletes all of a user's global prefs - * Assumes that the user is globalized - * @param User $user The user. - */ - public static function resetGlobalUserSettings( User $user ) { - if ( !isset( $user->mGlobalPrefs ) ) { - // Triggers User::loadOptions. - $user->getOption( '' ); - } - if ( count( $user->mGlobalPrefs ) ) { - self::getPrefsDB( DB_MASTER )->delete( - 'global_preferences', - [ 'gp_user' => self::getUserID( $user ) ], - __METHOD__ - ); - } - } - - /** - * Convenience function to check if we're on the global prefs page. - * @param IContextSource $context The context to use; if not set main request context is used. - * @return bool - */ - public static function onGlobalPrefsPage( $context = null ) { - $context = $context ?: RequestContext::getMain(); - return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' ); - } - - /** - * Convenience function to check if we're on the local - * prefs page - * - * @param IContextSource $context The context to use; if not set main request context is used. - * @return bool - */ - public static function onLocalPrefsPage( $context = null ) { - $context = $context ?: RequestContext::getMain(); - return $context->getTitle() - && $context->getTitle()->isSpecial( 'Preferences' ); - } -} diff --git a/includes/GlobalPreferencesFactory.php b/includes/GlobalPreferencesFactory.php new file mode 100644 index 0000000..edc1b90 --- /dev/null +++ b/includes/GlobalPreferencesFactory.php @@ -0,0 +1,284 @@ +<?php +/** + * Implements global preferences for MediaWiki + * + * @author Kunal Mehta <lego...@gmail.com> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later + * @file + * @ingroup Extensions + * + * Partially based off of work by Werdna + * https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790 + */ + +namespace GlobalPreferences; + +use CentralIdLookup; +use IContextSource; +use MediaWiki\MediaWikiServices; +use MediaWiki\Preferences\DefaultPreferencesFactory; +use RequestContext; +use SpecialPage; +use User; + +/** + * Global preferences. + * @package GlobalPreferences + */ +class GlobalPreferencesFactory extends DefaultPreferencesFactory { + + /** @var User */ + protected $user; + + /** + * "bad" preferences that we should remove from + * Special:GlobalPrefs + * @var array + */ + protected $prefsBlacklist = [ + // Stored in user table, doesn't work yet + 'realname', + // @todo Show CA user id / shared user table id? + 'userid', + // @todo Show CA global groups instead? + 'usergroups', + // @todo Should global edit count instead? + 'editcount', + 'registrationdate', + // Signature could be global, but links in it are too likely to break. + 'nickname', + 'fancysig', + ]; + + /** + * Preference types that we should not add a checkbox for + * @var array + */ + protected $typeBlacklist = [ + 'info', + 'hidden', + 'api', + ]; + + /** + * Preference classes that are allowed to be global + * @var array + */ + protected $classWhitelist = [ + 'HTMLSelectOrOtherField', + 'CirrusSearch\HTMLCompletionProfileSettings', + 'NewHTMLCheckField', + 'HTMLFeatureField', + ]; + + /** + * Set the preferences user. + * Note that not many of this class's methods use this, and you have to pass $user again. + * @TODO This should really be higher up the class hierarchy. + * @param User $user The user. + */ + public function setUser( User $user ) { + $this->user = $user; + } + + /** + * Get all user preferences. + * @param User $user The user. + * @param IContextSource $context The preferences page. + * @return array|null + */ + public function getFormDescriptor( User $user, IContextSource $context ) { + $this->setUser( $user ); + $globalPrefNames = array_keys( $this->getGlobalPreferencesValues() ); + $preferences = parent::getFormDescriptor( $user, $context ); + if ( $this->onGlobalPrefsPage() ) { + return $this->getPreferencesGlobal( $preferences, $globalPrefNames ); + } + return $this->getPreferencesLocal( $preferences, $globalPrefNames ); + } + + /** + * Add help-text to the local preferences where they're globalized, + * and add the link to Special:GlobalPreferences to the personal preferences tab. + * @param mixed[][] $preferences The preferences array. + * @param string[] $globalPrefNames The names of those preferences that are already global. + * @return mixed[][] + */ + protected function getPreferencesLocal( $preferences, $globalPrefNames ) { + foreach ( $preferences as $name => $def ) { + // If this has been set globally. + if ( in_array( $name, $globalPrefNames ) ) { + // Disable this preference. + $preferences[$name]['disabled'] = true; + + // Append a help message. + $help = ''; + if ( isset( $preferences[$name]['help-message'] ) ) { + $help .= wfMessage( $preferences[$name]['help-message'] )->parse() . '<br />'; + } elseif ( isset( $preferences[$name]['help'] ) ) { + $help .= $preferences[$name]['help'] . '<br />'; + } + + // Create a link to the relevant section of GlobalPreferences. + $section = substr( $def['section'], 0, strpos( $def['section'], '/' ) ); + $secFragment = 'mw-prefsection-' . $section; + + // Set the new full help text. + $help .= wfMessage( 'globalprefs-set-globally', [ $secFragment ] )->parse(); + $preferences[$name]['help'] = $help; + unset( $preferences[$name]['help-message'] ); + } + } + + // Add a link to GlobalPreferences to the local preferences form. + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $preferences['global-info'] = [ + 'type' => 'info', + 'section' => 'personal/info', + 'label-message' => 'globalprefs-info-label', + 'raw' => true, + 'default' => $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'GlobalPreferences' ), + wfMessage( 'globalprefs-info-link' )->escaped() + ), + ]; + + return $preferences; + } + + /** + * Add the '-global' counterparts to all preferences. + * @param mixed[][] $preferences The preferences array. + * @param string[] $globalPrefNames The names of those preferences that are already global. + * @return mixed[][] + */ + protected function getPreferencesGlobal( $preferences, $globalPrefNames ) { + // Add all corresponding new global fields. + $allPrefs = []; + foreach ( $preferences as $pref => $def ) { + // Ignore unwanted preferences. + if ( !$this->isGlobalizablePreference( $pref, $def ) ) { + continue; + } + // Create the new preference. + $allPrefs[$pref.'-global'] = [ + 'type' => 'toggle', + // Make the tooltip and the label the same, because the label is normally hidden. + 'tooltip' => 'globalprefs-check-label', + 'label-message' => 'tooltip-globalprefs-check-label', + 'default' => in_array( $pref, $globalPrefNames ), + 'section' => $def['section'], + 'cssclass' => 'mw-globalprefs-global-check mw-globalprefs-checkbox-for-' . $pref, + ]; + + $allPrefs[$pref] = $def; + } + return $allPrefs; + } + + /** + * Checks whether the given preference is globalizable. + * + * @param string $name Preference name + * @param mixed[] &$info Preference description, by reference to avoid unnecessary cloning + * @return bool + */ + protected function isGlobalizablePreference( $name, &$info ) { + // Preferences can opt out of being globalized by setting the 'noglobal' flag. + $hasOptedOut = ( isset( $info['noglobal'] ) && $info['noglobal'] === true ); + + $isAllowedType = isset( $info['type'] ) + && !in_array( $info['type'], $this->typeBlacklist ) + && !in_array( $name, $this->prefsBlacklist ); + + $isAllowedClass = isset( $info['class'] ) + && in_array( $info['class'], $this->classWhitelist ); + + $endsInGlobal = ( substr( $name, -strlen( '-global' ) ) === '-global' ); + + return !$hasOptedOut && !$endsInGlobal && ( $isAllowedType || $isAllowedClass ); + } + + /** + * Checks if the user is globalized. + * @return bool + */ + public function isUserGlobalized() { + if ( $this->user->isAnon() ) { + // No prefs for anons, sorry :( + return false; + } + return $this->getUserID() !== 0; + } + + /** + * Gets the user's ID that we're using in the table + * Returns 0 if the user is not global + * @return int + */ + public function getUserID() { + $lookup = CentralIdLookup::factory(); + return $lookup->centralIdFromLocalUser( $this->user, CentralIdLookup::AUDIENCE_RAW ); + } + + /** + * Get the user's global preferences. + * @return string[]|bool Array keyed by preference name, or false if not found. + */ + public function getGlobalPreferencesValues() { + $id = $this->getUserID(); + if ( !$id ) { + return false; + } + $storage = new Storage( $id ); + return $storage->load(); + } + + /** + * Save the user's global preferences. + * @param array $newGlobalPrefs Array keyed by preference name. + * @return bool True on success, false if the user isn't global. + */ + public function setGlobalPreferences( $newGlobalPrefs ) { + $id = $this->getUserID(); + if ( !$id ) { + return false; + } + $storage = new Storage( $this->getUserID() ); + $storage->save( $newGlobalPrefs ); + $this->user->clearInstanceCache(); + return true; + } + + /** + * Deletes all of a user's global preferences. + * Assumes that the user is globalized. + */ + public function resetGlobalUserSettings() { + $storage = new Storage( $this->getUserID() ); + $storage->delete(); + } + + /** + * Convenience function to check if we're on the global prefs page. + * @param IContextSource $context The context to use; if not set main request context is used. + * @return bool + */ + public function onGlobalPrefsPage( $context = null ) { + $context = $context ?: RequestContext::getMain(); + return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' ); + } + + /** + * Convenience function to check if we're on the local + * prefs page + * + * @param IContextSource $context The context to use; if not set main request context is used. + * @return bool + */ + public function onLocalPrefsPage( $context = null ) { + $context = $context ?: RequestContext::getMain(); + return $context->getTitle() + && $context->getTitle()->isSpecial( 'Preferences' ); + } +} diff --git a/includes/GlobalPreferencesForm.php b/includes/GlobalPreferencesForm.php new file mode 100644 index 0000000..5c36867 --- /dev/null +++ b/includes/GlobalPreferencesForm.php @@ -0,0 +1,52 @@ +<?php + +namespace GlobalPreferences; + +use Html; +use IContextSource; +use PreferencesForm; + +/** + * The GlobalPreferencesForm changes the display format, and adds section headers linking back to + * the local-preferences form. + * + * @package GlobalPreferences + */ +class GlobalPreferencesForm extends PreferencesForm { + + /** + * Build a new GlobalPreferencesForm from an array of field attributes, and force it to be + * have a 'div' display format. + * + * @param array $descriptor Array of Field constructs, as described above. + * @param IContextSource $context The context of the form. + * @param string $messagePrefix A prefix to go in front of default messages. + */ + public function __construct( $descriptor, IContextSource $context = null, $messagePrefix = '' ) { + parent::__construct( $descriptor, $context, $messagePrefix ); + $this->setDisplayFormat( 'div' ); + } + + /** + * Get the whole body of the form, adding the global preferences header text to the top of each + * section. Javascript will later add the 'select all' checkbox to this header. + * @return string + */ + function getBody() { + // Add help text to the top of every section. + foreach ( $this->getPreferenceSections() as $section ) { + $colHeaderText = Html::element( + 'span', + [ 'class' => 'col-header' ], + $this->getMessage( 'tooltip-globalprefs-check-label' ) + ); + $secHeader = Html::rawElement( + 'div', + [ 'class' => 'globalprefs-section-header' ], + $colHeaderText + ); + $this->addHeaderText( $secHeader, $section ); + } + return parent::getBody(); + } +} diff --git a/includes/Hooks.php b/includes/Hooks.php index 5a461fb..7f04f90 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -3,118 +3,56 @@ namespace GlobalPreferences; use DatabaseUpdater; -use Linker; +use Language; +use MediaWiki\Auth\AuthManager; +use MediaWiki\MediaWikiServices; use PreferencesForm; -use SpecialPage; use User; class Hooks { /** - * "bad" preferences that we should remove from - * Special:GlobalPrefs - * @var array - */ - protected static $prefsBlacklist = [ - // Stored in user table, doesn't work yet - 'realname', - // @todo Show CA user id / shared user table id? - 'userid', - // @todo Show CA global groups instead? - 'usergroups', - // @todo Should global edit count instead? - 'editcount', - 'registrationdate', - ]; - - /** - * Preference types that we should not add a checkbox for - * @var array - */ - protected static $typeBlacklist = [ - 'info', - 'hidden', - 'api', - ]; - - /** - * Preference classes that are allowed to be global - * @var array - */ - protected static $classWhitelist = [ - 'HTMLSelectOrOtherField', - 'CirrusSearch\HTMLCompletionProfileSettings', - 'NewHTMLCheckField', - 'HTMLFeatureField', - ]; - - /** - * @FIXME This is terrible - */ - public static function onExtensionFunctions() { - global $wgHooks; - // Register this as late as possible! - $wgHooks['GetPreferences'][] = self::class . '::onGetPreferences'; - } - - /** - * Load our global prefs + * Load global preferences. * @link https://www.mediawiki.org/wiki/Manual:Hooks/UserLoadOptions * @param User $user The user for whom options are being loaded. * @param array &$options The user's options; can be modified. - * @return bool */ public static function onUserLoadOptions( User $user, &$options ) { - $id = GlobalPreferences::getUserID( $user ); - if ( !$id ) { + /** @var GlobalPreferencesFactory $globalPreferences */ + $globalPreferences = MediaWikiServices::getInstance()->getPreferencesFactory(); + $globalPreferences->setUser( $user ); + if ( !$globalPreferences->isUserGlobalized() ) { // Not a global user. - return true; + return; } - $dbr = GlobalPreferences::getPrefsDB( DB_REPLICA ); - $res = $dbr->select( - 'global_preferences', - [ 'gp_property', 'gp_value' ], - [ 'gp_user' => $id ], - __METHOD__ - ); - - $user->mGlobalPrefs = []; - $user->mLocalPrefs = []; - - foreach ( $res as $row ) { - if ( isset( $user->mOptions[$row->gp_property] ) ) { - // Store the local one we will override - $user->mLocalPrefs[$row->gp_property] = $user->mOptions[$row->gp_property]; - } - $options[$row->gp_property] = $row->gp_value; - $user->mGlobalPrefs[] = $row->gp_property; + // Overwrite all options that have a global counterpart. + foreach ( $globalPreferences->getGlobalPreferencesValues() as $optName => $globalValue ) { + $options[ $optName ] = $globalValue; } - - return true; } /** - * Don't save global prefs - * @link https://www.mediawiki.org/wiki/Manual:Hooks/UserSaveOptions - * @param User $user The user for whom options are being saved. - * @param array &$options The user's options; can be modified. - * @return bool + * When saving a user's options, remove any global ones and never save any on the Global + * Preferences page. Global options are saved separately, in the PreferencesFormPreSave hook. + * @param User $user The user. Not used. + * @param string[] &$options The user's options. + * @return bool False if nothing changed, true otherwise. */ public static function onUserSaveOptions( User $user, &$options ) { - if ( GlobalPreferences::onGlobalPrefsPage() ) { + /** @var GlobalPreferencesFactory $preferencesFactory */ + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $preferencesFactory->setUser( $user ); + if ( $preferencesFactory->onGlobalPrefsPage() ) { // It shouldn't be possible to save local options here, // but never save on this page anyways. return false; } - foreach ( $user->mGlobalPrefs as $pref ) { - if ( isset( $options[$pref] ) ) { - unset( $options[$pref] ); - } - // But also save prefs we might have overrode... - if ( isset( $user->mLocalPrefs[$pref] ) ) { - $options[$pref] = $user->mLocalPrefs[$pref]; + foreach ( $options as $optName => $optVal ) { + // Ignore if ends in "-global". + if ( substr( $optName, -strlen( '-global' ) ) === '-global' ) { + unset( $options[ $optName ] ); } } @@ -135,17 +73,18 @@ User $user, &$result ) { - if ( !GlobalPreferences::onGlobalPrefsPage( $form ) ) { + /** @var GlobalPreferencesFactory $preferencesFactory */ + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + if ( !$preferencesFactory->onGlobalPrefsPage( $form ) ) { // Don't interfere with local preferences return true; } - $rows = []; $prefs = []; foreach ( $formData as $name => $value ) { if ( substr( $name, -strlen( 'global' ) ) === 'global' && $value === true ) { $realName = substr( $name, 0, -strlen( '-global' ) ); - if ( isset( $formData[$realName] ) && !in_array( $realName, self::$prefsBlacklist ) ) { + if ( isset( $formData[$realName] ) ) { $prefs[$realName] = $formData[$realName]; } else { // FIXME: Handle checkbox matrixes properly @@ -157,27 +96,10 @@ } } - $id = GlobalPreferences::getUserID( $user ); - foreach ( $prefs as $prop => $value ) { - $rows[] = [ - 'gp_user' => $id, - 'gp_property' => $prop, - 'gp_value' => $value, - ]; - - } - - // Reset preferences, and then save new ones - GlobalPreferences::resetGlobalUserSettings( $user ); - if ( $rows ) { - $dbw = GlobalPreferences::getPrefsDB( DB_MASTER ); - $dbw->replace( - 'global_preferences', - [ 'gp_user', 'gp_property' ], - $rows, - __METHOD__ - ); - } + /** @var GlobalPreferencesFactory $preferencesFactory */ + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $preferencesFactory->setUser( $user ); + $preferencesFactory->setGlobalPreferences( $prefs ); return false; } @@ -199,104 +121,23 @@ } /** - * @link https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences - * @param User $user User whose preferences are being modified. - * @param array &$prefs Preferences description array, to be fed to an HTMLForm object. - * @return bool + * Replace the PreferencesFactory service with the GlobalPreferencesFactory. + * @link https://www.mediawiki.org/wiki/Manual:Hooks/MediaWikiServices + * @param MediaWikiServices $services The services object to use. */ - public static function onGetPreferences( User $user, &$prefs ) { - if ( !GlobalPreferences::isUserGlobalized( $user ) ) { - return true; - } - - if ( GlobalPreferences::onGlobalPrefsPage() ) { - if ( !isset( $user->mGlobalPrefs ) ) { - // Just in case the user hasn't been loaded yet. Triggers User::loadOptions. - $user->getOption( '' ); - } - foreach ( $prefs as $name => $info ) { - // Preferences can opt out of being globalized by setting the 'noglobal' flag. - $hasOptedOut = ( isset( $info['noglobal'] ) && $info['noglobal'] === true ); - if ( $hasOptedOut ) { - unset( $prefs[ $name ] ); - continue; - } - - // FIXME: This whole code section sucks - if ( !isset( $prefs["$name-global"] ) - && self::isGlobalizablePreference( $name, $info ) - ) { - $prefs = wfArrayInsertAfter( $prefs, [ - "$name-global" => [ - 'type' => 'toggle', - 'label-message' => 'globalprefs-check-label', - 'default' => in_array( $name, $user->mGlobalPrefs ), - 'section' => $info['section'], - 'cssclass' => 'mw-globalprefs-global-check', - ] - ], $name ); - } elseif ( in_array( $name, self::$prefsBlacklist ) ) { - $prefs[$name]['type'] = 'hidden'; - } - } - } elseif ( GlobalPreferences::onLocalPrefsPage() ) { - if ( !isset( $user->mGlobalPrefs ) ) { - // Just in case the user hasn't been loaded yet. Triggers User::loadOptions. - $user->getOption( '' ); - } - foreach ( $user->mGlobalPrefs as $name ) { - if ( isset( $prefs[$name] ) ) { - $prefs[$name]['disabled'] = 'disabled'; - // Append a help message. - $help = ''; - if ( isset( $prefs[$name]['help-message'] ) ) { - $help .= wfMessage( $prefs[$name]['help-message'] )->parse() . '<br />'; - } elseif ( isset( $prefs[$name]['help'] ) ) { - $help .= $prefs[$name]['help'] . '<br />'; - } - - $help .= wfMessage( 'globalprefs-set-globally' )->parse(); - $prefs[$name]['help'] = $help; - unset( $prefs[$name]['help-message'] ); - - } - } - } - - // Provide a link to Special:GlobalPreferences - // if we're not on that page. - if ( !GlobalPreferences::onGlobalPrefsPage() ) { - $prefs['global-info'] = [ - 'type' => 'info', - 'section' => 'personal/info', - 'label-message' => 'globalprefs-info-label', - 'raw' => true, - 'default' => Linker::link( - SpecialPage::getTitleFor( 'GlobalPreferences' ), - wfMessage( 'globalprefs-info-link' )->escaped() - ), - ]; - } - - return true; - } - - /** - * Checks whether the given preference is localizable - * - * @param string $name Preference name - * @param array|mixed $info Preference description, by reference to avoid unnecessary cloning - * @return bool - */ - private static function isGlobalizablePreference( $name, &$info ) { - $isAllowedType = isset( $info['type'] ) - && !in_array( $info['type'], self::$typeBlacklist ) - && !in_array( $name, self::$prefsBlacklist ); - - $isAllowedClass = isset( $info['class'] ) - && in_array( $info['class'], self::$classWhitelist ); - - return substr( $name, -strlen( 'global' ) ) !== 'global' - && ( $isAllowedType || $isAllowedClass ); + public static function onMediaWikiServices( MediaWikiServices $services ) { + $services->redefineService( 'PreferencesFactory', function ( MediaWikiServices $services ) { + global $wgContLang, $wgLanguageCode; + $wgContLang = Language::factory( $wgLanguageCode ); + $wgContLang->initContLang(); + $authManager = AuthManager::singleton(); + $linkRenderer = $services->getLinkRendererFactory()->create(); + $config = $services->getMainConfig(); + return new GlobalPreferencesFactory( + $config, $wgContLang, $authManager, $linkRenderer + ); + } ); + // Now instantiate the new Preferences, to prevent it being overwritten. + $services->getPreferencesFactory(); } } diff --git a/includes/SpecialGlobalPreferences.php b/includes/SpecialGlobalPreferences.php index ec45a86..9cc3d6b 100644 --- a/includes/SpecialGlobalPreferences.php +++ b/includes/SpecialGlobalPreferences.php @@ -5,13 +5,18 @@ use DerivativeContext; use ErrorPageError; use HTMLForm; +use IContextSource; +use MediaWiki\MediaWikiServices; use PermissionsError; +use PreferencesForm; use SpecialPage; use SpecialPreferences; +use User; use UserNotLoggedIn; class SpecialGlobalPreferences extends SpecialPreferences { - function __construct() { + + public function __construct() { SpecialPage::__construct( 'GlobalPreferences' ); } @@ -34,7 +39,10 @@ $this->setHeaders(); throw new UserNotLoggedIn(); } - if ( !GlobalPreferences::isUserGlobalized( $this->getUser() ) ) { + /** @var GlobalPreferencesFactory $globalPreferencesFactory */ + $globalPreferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $globalPreferencesFactory->setUser( $this->getUser() ); + if ( !$globalPreferencesFactory->isUserGlobalized() ) { $this->setHeaders(); throw new ErrorPageError( 'globalprefs-error-header', 'globalprefs-notglobal' ); } @@ -52,6 +60,18 @@ $this->getOutput()->addModuleStyles( 'ext.GlobalPreferences.special.nojs' ); $this->getOutput()->addModules( 'ext.GlobalPreferences.special' ); parent::execute( $par ); + } + + /** + * Get the preferences form to use. + * @param User $user The user. + * @param IContextSource $context The context. + * @return PreferencesForm|HTMLForm + */ + protected function getFormObject( $user, IContextSource $context ) { + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $form = $preferencesFactory->getForm( $user, $context, GlobalPreferencesForm::class ); + return $form; } /** @@ -90,6 +110,15 @@ } /** + * Adds help link with an icon via page indicators. + * @param string $to Ignored. + * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o. + */ + public function addHelpLink( $to, $overrideBaseUrl = false ) { + parent::addHelpLink( 'Help:Extension:GlobalPreferences', $overrideBaseUrl ); + } + + /** * Handle reset submission (subpage '/reset'). * @param string[] $formData The submitted data (not used). * @return bool @@ -101,7 +130,10 @@ throw new PermissionsError( 'editmyoptions' ); } - GlobalPreferences::resetGlobalUserSettings( $this->getUser() ); + /** @var GlobalPreferencesFactory $preferencesFactory */ + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $preferencesFactory->setUser( $this->getUser() ); + $preferencesFactory->resetGlobalUserSettings(); $url = $this->getTitle()->getFullURL( 'success' ); @@ -109,5 +141,4 @@ return true; } - } diff --git a/includes/Storage.php b/includes/Storage.php new file mode 100644 index 0000000..a9c32eb --- /dev/null +++ b/includes/Storage.php @@ -0,0 +1,103 @@ +<?php +/** + * This file contains only the Storage class. + * @package GlobalPreferences + */ + +namespace GlobalPreferences; + +use Wikimedia\Rdbms\Database; + +/** + * This class handles all database storage of global preferences. + * @package GlobalPreferences + */ +class Storage { + + /** The non-prefixed name of the global preferences database table. */ + const TABLE_NAME = 'global_preferences'; + + /** @var int The global user ID. */ + protected $userId; + + /** + * Create a new Global Preferences Storage object for a given user. + * @param int $userId The global user ID. + */ + public function __construct( $userId ) { + $this->userId = $userId; + } + + /** + * Get the user's global preferences. + * @return string[] Keyed by the preference name. + */ + public function load() { + $dbr = $this->getDatabase( DB_REPLICA ); + $res = $dbr->select( + static::TABLE_NAME, + [ 'gp_property', 'gp_value' ], + [ 'gp_user' => $this->userId ], + __METHOD__ + ); + $preferences = []; + foreach ( $res as $row ) { + $preferences[$row->gp_property] = $row->gp_value; + } + return $preferences; + } + + /** + * Save a set of global preferences. All existing preferences will be deleted before the new + * ones are saved. + * @param string[] $newPrefs Keyed by the preference name. + */ + public function save( $newPrefs ) { + // Assemble the records to save. + $rows = []; + foreach ( $newPrefs as $prop => $value ) { + $rows[] = [ + 'gp_user' => $this->userId, + 'gp_property' => $prop, + 'gp_value' => $value, + ]; + } + // Delete all global preferences, and then save new ones. + $this->delete(); + if ( $rows ) { + $dbw = $this->getDatabase( DB_MASTER ); + $dbw->replace( + static::TABLE_NAME, + [ 'gp_user', 'gp_property' ], + $rows, + __METHOD__ + ); + } + } + + /** + * Delete all of this user's global preferences. + */ + public function delete() { + $db = $this->getDatabase( DB_MASTER ); + $db->delete( + static::TABLE_NAME, + [ 'gp_user' => $this->userId ], + __METHOD__ + ); + } + + /** + * Get the database object pointing to the Global Preferences database. + * @param int $type One of the DB_* constants + * @return Database + */ + protected function getDatabase( $type = DB_REPLICA ) { + global $wgGlobalPreferencesDB; + if ( $wgGlobalPreferencesDB ) { + return wfGetDB( $type, [], $wgGlobalPreferencesDB ); + } else { + return wfGetDB( $type ); + } + } +} diff --git a/resources/ext.GlobalPreferences.special.css b/resources/ext.GlobalPreferences.special.css index ef5a087..4d42ba8 100644 --- a/resources/ext.GlobalPreferences.special.css +++ b/resources/ext.GlobalPreferences.special.css @@ -1,9 +1,15 @@ -.globalprefs-disabled { - opacity: 0.5; -} .globalprefs-hover { - background-color: rgba( 255, 224, 97, 0.4 ); + background-color: #eaecf0; } -.mw-special-GlobalPreferences form.mw-htmlform table { - border-collapse: collapse; + +/* Style fixes for Skin:Vector. The ID selector is whitelisted in .stylelintrc because Vector uses it. */ +body.skin-vector #preferences fieldset.ext-globalpreferences-select-all { + padding-bottom: 0; + margin-bottom: 0; + border: 0; +} + +/* Style fixes for Extension:BetaFeatures. */ +body.skin-vector #preferences #mw-prefsection-betafeatures fieldset.ext-globalpreferences-select-all { + padding-left: 0; } diff --git a/resources/ext.GlobalPreferences.special.js b/resources/ext.GlobalPreferences.special.js index 59658de..ed1d045 100644 --- a/resources/ext.GlobalPreferences.special.js +++ b/resources/ext.GlobalPreferences.special.js @@ -1,7 +1,11 @@ ( function ( mw, $ ) { 'use strict'; - $( 'input.mw-globalprefs-global-check' ).on( 'change', function () { + /** + * When one of the global checkboxes is changed enable or disable its matching preference. + * Also highlight the relevant preference when hovering on the checkbox. + */ + function onChangeGlobalCheckboxes() { var $labels, // Find the name (without the '-global' suffix, but with the 'wp' prefix). @@ -11,44 +15,93 @@ // Is this preference enabled globally? enabled = $( this ).prop( 'checked' ), - // The table rows relating to this preference - // (two or three rows, depending on whether there's a help row). + // This selector is required because there's no common class on these. + fieldSelector = '[class^="mw-htmlform-field-"]', + + // The form 'rows' (which are adjacent divs) relating to this preference + // (two or three rows, depending on whether there's a help row, all contained in $rows). $globalCheckRow, - $labelRow, - $rows; + $mainFieldRow, + $rows, + + // The current preference's inputs (can be multiple, and not all will have the same name). + $inputs = $( ':input[name="' + name + '"]' ).parents( '.mw-input' ).find( ':input' ); // All the labels for this preference (not all have for=''). - $labels = $( 'label[for^=\'mw-input-' + name + '\']' ) - .closest( 'tr' ) + $labels = $inputs + .closest( fieldSelector ) .find( 'label' ) - .not( '[for$=\'-global\']' ); + .not( '[for$="-global"]' ); + + // Collect the related rows. The main field row is sometimes followed by a help-tip row. + $globalCheckRow = $( this ).closest( fieldSelector ); + $mainFieldRow = $labels.closest( fieldSelector ); + $rows = $().add( $globalCheckRow ).add( $mainFieldRow ); + if ( $mainFieldRow.next().hasClass( 'htmlform-tip' ) ) { + $rows = $rows.add( $mainFieldRow.next() ); + } // Disable or enable the related preferences inputs. - $( ':input[name=\'' + name + '\']' ).prop( 'disabled', !enabled ); + $inputs.prop( 'disabled', !enabled ); if ( enabled ) { $labels.removeClass( 'globalprefs-disabled' ); } else { $labels.addClass( 'globalprefs-disabled' ); } - // Collect the related rows. The latter two in the $rows array will often be the same element. - $globalCheckRow = $( this ).closest( 'tr' ); - $labelRow = $labels.closest( 'tr' ); - $rows = $( [ - $labelRow[ 0 ], - $labelRow.next()[ 0 ], - $globalCheckRow[ 0 ] - ] ); - // Add a class on hover, to highlight the related rows. - $( this ).add( 'label[for=\'' + $( this ).attr( 'id' ) + '\']' ).hover( function () { - // Hover on. - $rows.addClass( 'globalprefs-hover' ); - }, function () { - // Hover off. - $rows.removeClass( 'globalprefs-hover' ); + $( this ).add( 'label[for="' + $( this ).attr( 'id' ) + '"]' ).on( { + mouseenter: function () { + $rows.addClass( 'globalprefs-hover' ); + }, + mouseleave: function () { + $rows.removeClass( 'globalprefs-hover' ); + } } ); + } - } ).change(); + /** + * Add select all behaviour to a group of checkboxes. + * @param {jQuery} $selectAll The select-all checkbox. + * @param {jQuery} $targets The target checkboxes. + */ + function selectAllCheckboxes( $selectAll, $targets ) { + // Handle the select-all box changing. + $selectAll.on( 'change', function () { + $targets.prop( 'checked', $( this ).prop( 'checked' ) ).change(); + } ); + // Handle any of the targets changing. + $targets.on( 'change', function () { + var allSelected = true; + $targets.each( function () { + allSelected = allSelected && $( this ).prop( 'checked' ); + } ); + $selectAll.prop( 'checked', allSelected ); + } ); + } + /** + * Add the 'select all' checkbox to the form section headers. + */ + function addSelectAllToHeader() { + // For each preferences form tab, add a select-all checkbox to the header. + $( '.globalprefs-section-header' ).each( function () { + var selectAll = mw.message( 'globalprefs-select-all' ), + $checkbox, + $allGlobalCheckboxes; + // Wrap the checkbox in a fieldset so it acts/looks the same as all the global checkboxes. + $checkbox = $( '<fieldset class="ext-globalpreferences-select-all"><label><input type="checkbox" /> ' + selectAll + '</label></fieldset>' ); + $( this ).append( $checkbox ); + + // Determine all the matching checkboxes. + $allGlobalCheckboxes = $( this ).parent( 'fieldset' ).find( '.mw-globalprefs-global-check:checkbox' ); + + // Enable the select-all behaviour. + selectAllCheckboxes( $checkbox.find( ':checkbox' ), $allGlobalCheckboxes ); + } ); + } + + // Activate the above functions. + addSelectAllToHeader(); + $( 'input.mw-globalprefs-global-check' ).on( 'change', onChangeGlobalCheckboxes ).change(); }( mediaWiki, jQuery ) ); diff --git a/resources/ext.GlobalPreferences.special.nojs.css b/resources/ext.GlobalPreferences.special.nojs.css index 71deae3..e8a0f78 100644 --- a/resources/ext.GlobalPreferences.special.nojs.css +++ b/resources/ext.GlobalPreferences.special.nojs.css @@ -1,6 +1,80 @@ -input.mw-globalprefs-global-check { - margin-left: 2em; +.mw-htmlform-nolabel .mw-label { + display: none; } -tr.mw-globalprefs-global-check { + +.mw-globalprefs-global-check .mw-label, +.mw-globalprefs-global-check label { + display: none; +} + +/* For core fields */ +.mw-htmlform-field-HTMLAutoCompleteSelectField, +.mw-htmlform-field-HTMLButtonField, +.mw-htmlform-field-HTMLCheckField, +.mw-htmlform-field-HTMLCheckMatrix, +.mw-htmlform-field-HTMLComboboxField, +.mw-htmlform-field-HTMLDateTimeField, +.mw-htmlform-field-HTMLEditTools, +.mw-htmlform-field-HTMLFloatField, +.mw-htmlform-field-HTMLFormFieldCloner, +.mw-htmlform-field-HTMLFormFieldWithButton, +.mw-htmlform-field-HTMLHiddenField, +.mw-htmlform-field-HTMLInfoField, +.mw-htmlform-field-HTMLIntField, +.mw-htmlform-field-HTMLMultiSelectField, +.mw-htmlform-field-HTMLRadioField, +.mw-htmlform-field-HTMLRestrictionsField, +.mw-htmlform-field-HTMLSelectAndOtherField, +.mw-htmlform-field-HTMLSelectField, +.mw-htmlform-field-HTMLSelectLimitField, +.mw-htmlform-field-HTMLSelectNamespace, +.mw-htmlform-field-HTMLSelectNamespaceWithButton, +.mw-htmlform-field-HTMLSelectOrOtherField, +.mw-htmlform-field-HTMLSizeFilterField, +.mw-htmlform-field-HTMLSubmitField, +.mw-htmlform-field-HTMLTagFilter, +.mw-htmlform-field-HTMLTextAreaField, +.mw-htmlform-field-HTMLTextField, +.mw-htmlform-field-HTMLTextFieldWithButton, +.mw-htmlform-field-HTMLTitleTextField, +.mw-htmlform-field-HTMLUsersMultiselectField, +.mw-htmlform-field-HTMLUserTextField, +/* For Extension:BetaFeatures */ +.mw-htmlform-field-NewHTMLCheckField, +.mw-htmlform-field-HTMLFeatureField, +/* For Extension:GlobalPrefences */ +.mw-special-GlobalPreferences .htmlform-tip { + padding-left: 7%; +} + +.mw-globalprefs-global-check { font-size: smaller; + padding-left: 0; + float: left; + width: 5%; +} +.mw-globalprefs-global-check input.mw-globalprefs-global-check { + float: none; + width: auto; +} + +/** Make the column header a bit narrower so it looks more associated with the column. */ +.globalprefs-section-header .col-header { + display: block; + width: 7%; + text-align: center; +} + +/* Style fixes for Extension:BetaFeatures. */ +fieldset#mw-prefsection-betafeatures .mw-globalprefs-global-check .mw-input { + /* To match .mw-htmlform-field-HTMLFeatureField .mw-input */ + padding-top: 10px; + /* To match .oo-ui-fieldLayout.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header > .oo-ui-labelElement-label */ + font-size: 24px; + margin-top: 0.3em; +} +fieldset#mw-prefsection-betafeatures .mw-globalprefs-checkbox-for-betafeatures-auto-enroll .mw-input { + padding-top: 0; + font-size: inherit; + margin-top: 0; } diff --git a/tests/phpunit/GlobalPreferencesTest.php b/tests/phpunit/GlobalPreferencesTest.php new file mode 100644 index 0000000..fa43b99 --- /dev/null +++ b/tests/phpunit/GlobalPreferencesTest.php @@ -0,0 +1,69 @@ +<?php + +namespace GlobalPreferences\Test; + +use GlobalPreferences\GlobalPreferencesFactory; +use GlobalPreferences\Storage; +use MediaWiki\MediaWikiServices; +use MediaWikiTestCase; + +/** + * @group GlobalPreferences + */ +class GlobalPreferencesTest extends MediaWikiTestCase { + + public function testService() { + $factory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $this->assertInstanceOf( GlobalPreferencesFactory::class, $factory ); + } + + public function testStorage() { + $user = $this->getTestUser()->getUser(); + $gpStorage = new Storage( $user->getId() ); + + // No prefs to start with. + $this->assertEmpty( $gpStorage->load() ); + + // Save one, and retrieve it. + $gpStorage->save( [ 'testpref' => 'test' ] ); + $this->assertCount( 1, $gpStorage->load() ); + + // Save different ones, and it should overwrite. + $gpStorage->save( [ 'testpref2' => 'test2' ] ); + $this->assertCount( 1, $gpStorage->load() ); + $gpStorage->save( [ 'testpref2' => 'test2', 'testpref3' => 'test3' ] ); + $this->assertCount( 2, $gpStorage->load() ); + + // Delete all + $gpStorage->delete(); + $this->assertEmpty( $gpStorage->load() ); + } + + public function testUserPreference() { + $user = $this->getTestUser()->getUser(); + /** @var GlobalPreferencesFactory $globalPreferences */ + $globalPreferences = MediaWikiServices::getInstance()->getPreferencesFactory(); + $globalPreferences->setUser( $user ); + + // Confirm the site default. + $this->assertEquals( 'en', $user->getOption( 'language' ) ); + + // Set a local preference. + $user->setOption( 'language', 'bn' ); + $user->saveSettings(); + $this->assertEquals( 'bn', $user->getOption( 'language' ) ); + + // Set it to be global (with a different value). + $globalPreferences->setGlobalPreferences( [ 'language' => 'de' ] ); + $this->assertEquals( [ 'language' => 'de' ], $globalPreferences->getGlobalPreferencesValues() ); + $this->assertEquals( 'de', $user->getOption( 'language' ) ); + $globalPreferences->setGlobalPreferences( [ 'language' => 'ru' ] ); + $this->assertEquals( 'ru', $user->getOption( 'language' ) ); + + // Then unglobalize it, and it should return to the local value. + $globalPreferences->setGlobalPreferences( [] ); + $this->assertEquals( [], $globalPreferences->getGlobalPreferencesValues() ); + // @TODO Instance caching on User doesn't clear User::$mOptionOverrides + // $this->assertEquals( 'bn', $user->getOption( 'language' ) ); + } +} -- To view, visit https://gerrit.wikimedia.org/r/370152 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I3c10dfeacf02367e90f84a3e572ecf3f4048e02a Gerrit-PatchSet: 43 Gerrit-Project: mediawiki/extensions/GlobalPreferences Gerrit-Branch: master Gerrit-Owner: Samwilson <s...@samwilson.id.au> Gerrit-Reviewer: Daniel Kinzler <daniel.kinz...@wikimedia.de> Gerrit-Reviewer: MaxSem <maxsem.w...@gmail.com> Gerrit-Reviewer: Niharika29 <nko...@wikimedia.org> Gerrit-Reviewer: Samwilson <s...@samwilson.id.au> Gerrit-Reviewer: Siebrand <siebr...@kitano.nl> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits