| <?php | |
| /* | |
| * This file is part of the MediaWiki extension BetaFeatures. | |
| * | |
| * BetaFeatures is free software: you can redistribute it and/or modify | |
| * it under the terms of the GNU General Public License as published by | |
| * the Free Software Foundation, either version 2 of the License, or | |
| * (at your option) any later version. | |
| * | |
| * BetaFeatures is distributed in the hope that it will be useful, | |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| * GNU General Public License for more details. | |
| * | |
| * You should have received a copy of the GNU General Public License | |
| * along with BetaFeatures. If not, see <http://www.gnu.org/licenses/>. | |
| * | |
| * BetaFeatures extension hooks | |
| * | |
| * @file | |
| * @ingroup Extensions | |
| * @copyright 2013 Mark Holmquist and others; see AUTHORS | |
| * @license GNU General Public License version 2 or later | |
| */ | |
| namespace MediaWiki\Extension\BetaFeatures; | |
| use DatabaseUpdater; | |
| use DeferredUpdates; | |
| use Exception; | |
| use Hooks as MWHooks; | |
| use JobQueueGroup; | |
| use MediaWiki\MediaWikiServices; | |
| use MediaWiki\User\UserIdentity; | |
| use ObjectCache; | |
| use RequestContext; | |
| use SkinTemplate; | |
| use SpecialPage; | |
| use Title; | |
| use User; | |
| class Hooks { | |
| /** | |
| * @var array An array of each of the available Beta Features, with their requirements, if any. | |
| * It is passed client-side for JavaScript rendering/responsiveness. | |
| */ | |
| private static $features = []; | |
| /** | |
| * @param string[] $prefs | |
| * @return int[] | |
| */ | |
| public static function getUserCounts( array $prefs ) { | |
| $counts = []; | |
| if ( !$prefs ) { | |
| return $counts; | |
| } | |
| $dbr = wfGetDB( DB_REPLICA ); | |
| $res = $dbr->select( | |
| 'betafeatures_user_counts', | |
| [ 'feature', 'number' ], | |
| [ 'feature' => $prefs ], | |
| __METHOD__ | |
| ); | |
| foreach ( $res as $row ) { | |
| $counts[$row->feature] = $row->number; | |
| } | |
| return $counts; | |
| } | |
| /** | |
| * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions | |
| * | |
| * @param UserIdentity $user User who's just saved their preferences | |
| * @param array $modifiedOptions List of modified options | |
| * @param array $originalOptions List of original user options | |
| * @throws Exception | |
| */ | |
| public static function updateUserCounts( | |
| UserIdentity $user, | |
| array $modifiedOptions, | |
| array $originalOptions | |
| ) { | |
| global $wgBetaFeatures; | |
| if ( !$user->isRegistered() ) { | |
| // Anonymous users do not have options, shorten out. | |
| return; | |
| } | |
| $betaFeatures = $wgBetaFeatures; | |
| $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $user ); | |
| MWHooks::run( 'GetBetaFeaturePreferences', [ $user, &$betaFeatures ] ); | |
| foreach ( $betaFeatures as $name => $option ) { | |
| if ( !array_key_exists( $name, $modifiedOptions ) ) { | |
| continue; | |
| } | |
| $newVal = $modifiedOptions[$name]; | |
| $oldVal = $originalOptions[$name] ?? null; | |
| // Check if this preference meaningfully changed | |
| if ( $oldVal === $newVal || | |
| ( $oldVal === null && $newVal === HTMLFeatureField::OPTION_DISABLED ) | |
| ) { | |
| // unchanged | |
| continue; | |
| } | |
| // Enqueue a job to update the count for this preference | |
| JobQueueGroup::singleton()->push( | |
| new UpdateBetaFeatureUserCountsJob( | |
| [ 'prefs' => [ $name ] ] | |
| ) | |
| ); | |
| } | |
| } | |
| /** | |
| * @param User $user | |
| * @param array[] &$prefs | |
| * @throws BetaFeaturesMissingFieldException | |
| */ | |
| public static function getPreferences( User $user, array &$prefs ) { | |
| global $wgBetaFeaturesWhitelist, $wgBetaFeatures, $wgHiddenPrefs; | |
| $betaPrefs = $wgBetaFeatures; | |
| $depHooks = []; | |
| MWHooks::run( 'GetBetaFeaturePreferences', [ $user, &$betaPrefs ] ); | |
| $prefs['betafeatures-section-desc'] = [ | |
| 'type' => 'info', | |
| 'default' => | |
| wfMessage( 'betafeatures-section-desc' )->numParams( count( $betaPrefs ) )->parseAsBlock(), | |
| 'section' => 'betafeatures', | |
| 'raw' => true, | |
| ]; | |
| $prefs['betafeatures-auto-enroll'] = [ | |
| 'class' => NewHTMLCheckField::class, | |
| 'label-message' => 'betafeatures-auto-enroll', | |
| 'help-message' => 'betafeatures-auto-enroll-help', | |
| 'section' => 'betafeatures', | |
| ]; | |
| // Purely visual field. | |
| $prefs['betafeatures-breaking-hr'] = [ | |
| 'class' => HTMLHorizontalRuleField::class, | |
| 'section' => 'betafeatures', | |
| ]; | |
| $counts = self::getUserCounts( array_keys( $betaPrefs ) ); | |
| // Set up dependency hooks array | |
| // This complex structure brought to you by Per-Wiki Configuration, | |
| // coming soon to a wiki very near you. | |
| MWHooks::run( 'GetBetaFeatureDependencyHooks', [ &$depHooks ] ); | |
| $autoEnrollSaveSettings = []; | |
| $autoEnrollAll = | |
| $user->getOption( 'betafeatures-auto-enroll' ) === HTMLFeatureField::OPTION_ENABLED; | |
| $autoEnroll = []; | |
| foreach ( $betaPrefs as $key => $info ) { | |
| if ( isset( $info['auto-enrollment'] ) ) { | |
| $autoEnroll[$info['auto-enrollment']] = $key; | |
| } | |
| } | |
| $userOptionsManager = MediaWikiServices::getInstance()->getUserOptionsManager(); | |
| foreach ( $betaPrefs as $key => $info ) { | |
| // Check if feature should be skipped | |
| if ( | |
| // Check if feature is hidden | |
| in_array( $key, $wgHiddenPrefs ) || | |
| // Check if feature is whitelisted | |
| ( | |
| is_array( $wgBetaFeaturesWhitelist ) && | |
| !in_array( $key, $wgBetaFeaturesWhitelist ) | |
| ) || | |
| // Check if dependencies are set but not met | |
| ( | |
| isset( $info['dependent'] ) && | |
| $info['dependent'] === true && | |
| isset( $depHooks[$key] ) && | |
| !MWHooks::run( $depHooks[$key] ) | |
| ) | |
| ) { | |
| continue; | |
| } | |
| $opt = [ | |
| 'class' => HTMLFeatureField::class, | |
| 'section' => 'betafeatures', | |
| ]; | |
| $requiredFields = [ | |
| 'label-message' => true, | |
| 'desc-message' => true, | |
| 'screenshot' => false, | |
| 'requirements' => false, | |
| 'info-link' => false, | |
| 'info-message' => false, | |
| 'discussion-link' => false, | |
| 'discussion-message' => false, | |
| 'disabled' => false, | |
| ]; | |
| foreach ( $requiredFields as $field => $required ) { | |
| if ( isset( $info[$field] ) ) { | |
| $opt[$field] = $info[$field]; | |
| } elseif ( $required ) { | |
| // A required field isn't present in the info array | |
| // we got from the GetBetaFeaturePreferences hook. | |
| // Don't add this feature to the form. | |
| throw new BetaFeaturesMissingFieldException( | |
| "The field {$field} was missing from the beta feature {$key}." | |
| ); | |
| } | |
| } | |
| if ( isset( $counts[$key] ) ) { | |
| $opt['user-count'] = $counts[$key]; | |
| } | |
| // Set the beta feature in the standard preferences array | |
| // Just before, unset the key to resort it in the array, in the case the key was already set | |
| unset( $prefs[$key] ); | |
| $prefs[$key] = $opt; | |
| $currentValue = $user->getOption( $key ); | |
| $autoEnrollForThisPref = false; | |
| if ( isset( $info['group'] ) && isset( $autoEnroll[$info['group']] ) ) { | |
| $autoEnrollForThisPref = | |
| $user->getOption( $autoEnroll[$info['group']] ) === HTMLFeatureField::OPTION_ENABLED; | |
| } | |
| $exemptAutoEnroll = ( $info['exempt-from-auto-enrollment'] ?? false ) | |
| || ( $info['disabled'] ?? false ); | |
| $autoEnrollHere = !$exemptAutoEnroll | |
| && ( $autoEnrollAll || $autoEnrollForThisPref ); | |
| if ( $autoEnrollHere ) { | |
| // Preferences controlled by the auto-enroller can't be changed individually when it's on | |
| $prefs[$key]['disabled'] = true; | |
| if ( $currentValue !== HTMLFeatureField::OPTION_ENABLED && | |
| $currentValue !== HTMLFeatureField::OPTION_DISABLED ) { | |
| // We haven't seen this before, and the user has auto-enroll enabled! | |
| // Set the option to true and make it visible for the current user object | |
| $userOptionsManager->setOption( $user, $key, HTMLFeatureField::OPTION_ENABLED ); | |
| // Also put it aside for saving the settings later | |
| $autoEnrollSaveSettings[$key] = HTMLFeatureField::OPTION_ENABLED; | |
| } | |
| } | |
| self::$features[$key] = []; | |
| self::$features[$key]['__skip-auto-enroll'] = $exemptAutoEnroll; | |
| } | |
| foreach ( $betaPrefs as $key => $info ) { | |
| if ( isset( $prefs[$key]['requirements'] ) ) { | |
| // Check which other beta features are required, and fetch their labels | |
| if ( isset( $prefs[$key]['requirements']['betafeatures'] ) ) { | |
| $requiredPrefs = []; | |
| foreach ( $prefs[$key]['requirements']['betafeatures'] as $preference ) { | |
| if ( !$user->getOption( $preference ) ) { | |
| $requiredPrefs[] = $prefs[$preference]['label-message']; | |
| } | |
| } | |
| if ( count( $requiredPrefs ) ) { | |
| $prefs[$key]['requirements']['betafeatures-messages'] = $requiredPrefs; | |
| } | |
| } | |
| // Test skin support | |
| if ( isset( $prefs[$key]['requirements']['skins'] ) ) { | |
| $skinFactory = MediaWikiServices::getInstance()->getSkinFactory(); | |
| // Remove any skins that aren't installed or users can't choose | |
| $prefs[$key]['requirements']['skins'] = array_intersect( | |
| /** @phan-suppress-next-line PhanTypeInvalidDimOffset,PhanTypeMismatchArgumentInternal */ | |
| $prefs[$key]['requirements']['skins'], | |
| array_keys( $skinFactory->getAllowedSkins() ) | |
| ); | |
| if ( empty( $prefs[$key]['requirements']['skins'] ) ) { | |
| // If there are no valid skins, don't show the preference | |
| wfDebugLog( 'BetaFeatures', "The $key BetaFeature has no valid skins installed." ); | |
| continue; | |
| } | |
| // Also check if the user's current skin is supported | |
| $prefs[$key]['requirements']['skin-not-supported'] = !in_array( | |
| RequestContext::getMain()->getSkin()->getSkinName(), | |
| $prefs[$key]['requirements']['skins'] | |
| ); | |
| } | |
| } | |
| // If a unsupported browsers list is supplied, store so it can be passed as JSON | |
| self::$features[$key]['unsupportedList'] = $prefs[$key]['requirements']['unsupportedList'] ?? | |
| // @deprecated since 1.35, use unsupportedList instead of blacklist | |
| ( $prefs[$key]['requirements']['blacklist'] ?? null ); | |
| } | |
| if ( $autoEnrollSaveSettings !== [] ) { | |
| // Save the preferences to the DB post-send | |
| DeferredUpdates::addCallableUpdate( | |
| static function () use ( $user, $autoEnrollSaveSettings, $userOptionsManager ) { | |
| $cache = ObjectCache::getLocalClusterInstance(); | |
| $key = $cache->makeKey( __CLASS__, 'prefs-update', $user->getId() ); | |
| // T95839: If concurrent requests pile on (e.g. multiple tabs), only let one | |
| // thread bother doing these updates. This avoids pointless error log spam. | |
| if ( $cache->lock( $key, 0, $cache::TTL_MINUTE ) ) { | |
| // Refresh, because the settings could be changed in the meantime by api or special page | |
| $userLatest = $user->getInstanceForUpdate(); | |
| // Apply the settings and save | |
| foreach ( $autoEnrollSaveSettings as $key => $option ) { | |
| $userOptionsManager->setOption( $userLatest, $key, $option ); | |
| } | |
| $userOptionsManager->saveOptions( $userLatest ); | |
| $cache->unlock( $key ); | |
| } | |
| } | |
| ); | |
| } | |
| } | |
| /** | |
| * @param array &$vars | |
| */ | |
| public static function onMakeGlobalVariablesScript( array &$vars ) { | |
| if ( self::$features ) { | |
| // This is added to page view HTML on all articles. | |
| // FIXME: Move this to the preferences page somehow, or | |
| // bundle with the module that loads betafeatures.js. | |
| $vars['wgBetaFeaturesFeatures'] = self::$features; | |
| } | |
| } | |
| /** | |
| * @param array[] &$personal_urls | |
| * @param Title $title | |
| * @param SkinTemplate $skintemplate | |
| */ | |
| public static function getBetaFeaturesLink( | |
| array &$personal_urls, | |
| Title $title, | |
| SkinTemplate $skintemplate | |
| ) { | |
| $user = $skintemplate->getUser(); | |
| if ( $user->isRegistered() ) { | |
| $personal_urls = wfArrayInsertAfter( $personal_urls, [ | |
| 'betafeatures' => [ | |
| 'text' => wfMessage( 'betafeatures-toplink' )->text(), | |
| 'href' => SpecialPage::getTitleFor( | |
| 'Preferences', false, 'mw-prefsection-betafeatures' | |
| )->getLinkURL(), | |
| 'active' => $title->isSpecial( 'Preferences' ), | |
| 'icon' => 'labFlask' | |
| ], | |
| ], 'preferences' ); | |
| } | |
| } | |
| /** | |
| * @param DatabaseUpdater $updater | |
| */ | |
| public static function getSchemaUpdates( DatabaseUpdater $updater ) { | |
| $dbType = $updater->getDB()->getType(); | |
| if ( $dbType === 'mysql' ) { | |
| $updater->addExtensionTable( 'betafeatures_user_counts', | |
| dirname( __DIR__ ) . '/sql/tables-generated.sql' | |
| ); | |
| } elseif ( $dbType === 'sqlite' ) { | |
| $updater->addExtensionTable( 'betafeatures_user_counts', | |
| dirname( __DIR__ ) . '/sql/sqlite/tables-generated.sql' | |
| ); | |
| } elseif ( $dbType === 'postgres' ) { | |
| $updater->addExtensionTable( 'betafeatures_user_counts', | |
| dirname( __DIR__ ) . '/sql/postgres/tables-generated.sql' | |
| ); | |
| } | |
| } | |
| /** | |
| * @param string[] &$extTypes | |
| */ | |
| public static function onExtensionTypes( array &$extTypes ) { | |
| $extTypes['betafeatures'] = wfMessage( 'betafeatures-extension-type' )->escaped(); | |
| } | |
| } |
US