* Assertion failures from the 'assert' and 'assertuser' parameters will no
longer use the action module's custom response format, for the few modules
that use custom formatters that handle errors.
+* (T198935) User list preferences such as `email-blacklist` and similar
+ extension preferences are no longer represented as arrays when returned by
+ action=query&meta=userinfo&uiprop=options.
=== Action API internal changes in 1.32 ===
* Added 'ApiParseMakeOutputPage' hook.
the Parsoid v3 API in May 2015.
* $input is deprecated in hook 'LogEventsListGetExtraInputs'. Use
$formDescriptor instead.
+* SearchEngine::transformSearchTerm( $term ) should no longer be called prior
+ to running searchText. This method was mainly implemented to support the
+ 'prefix' URI param in SpecialSearch, but there are no reasons to expose this
+ logic as it should be handled internally by SearchEngine implementations
+ supporting this feature. SearchEngine implementations should no longer
+ override this methods.
+* SearchEngine::replacePrefixes( $query ) should no longer be called prior
+ to running searchText/searchTitle.
=== Other changes in 1.32 ===
* (T198811) The following tables have had their UNIQUE indexes turned into
"mediawiki/mediawiki-codesniffer": "20.0.0",
"monolog/monolog": "~1.22.1",
"nikic/php-parser": "3.1.3",
+ "seld/jsonlint": "1.7.1",
"nmred/kafka-php": "0.1.5",
"phpunit/phpunit": "4.8.36 || ^6.5",
"psy/psysh": "0.9.6",
* Create a PageProps object
*/
private function __construct() {
- $this->cache = new ProcessCacheLRU( self::CACHE_SIZE );
+ $this->cache = new MapCacheLRU( self::CACHE_SIZE );
}
/**
* @param int $size
*/
public function ensureCacheSize( $size ) {
- if ( $this->cache->getSize() < $size ) {
- $this->cache->resize( $size );
+ if ( $this->cache->getMaxSize() < $size ) {
+ $this->cache->setMaxSize( $size );
}
}
* @return string|bool property value array or false if not found
*/
private function getCachedProperty( $pageID, $propertyName ) {
- if ( $this->cache->has( $pageID, $propertyName, self::CACHE_TTL ) ) {
- return $this->cache->get( $pageID, $propertyName );
+ if ( $this->cache->hasField( $pageID, $propertyName, self::CACHE_TTL ) ) {
+ return $this->cache->getField( $pageID, $propertyName );
}
- if ( $this->cache->has( 0, $pageID, self::CACHE_TTL ) ) {
- $pageProperties = $this->cache->get( 0, $pageID );
+ if ( $this->cache->hasField( 0, $pageID, self::CACHE_TTL ) ) {
+ $pageProperties = $this->cache->getField( 0, $pageID );
if ( isset( $pageProperties[$propertyName] ) ) {
return $pageProperties[$propertyName];
}
* @return string|bool property value array or false if not found
*/
private function getCachedProperties( $pageID ) {
- if ( $this->cache->has( 0, $pageID, self::CACHE_TTL ) ) {
- return $this->cache->get( 0, $pageID );
+ if ( $this->cache->hasField( 0, $pageID, self::CACHE_TTL ) ) {
+ return $this->cache->getField( 0, $pageID );
}
return false;
}
* @param mixed $propertyValue value of property
*/
private function cacheProperty( $pageID, $propertyName, $propertyValue ) {
- $this->cache->set( $pageID, $propertyName, $propertyValue );
+ $this->cache->setField( $pageID, $propertyName, $propertyValue );
}
/**
*/
private function cacheProperties( $pageID, $pageProperties ) {
$this->cache->clear( $pageID );
- $this->cache->set( 0, $pageID, $pageProperties );
+ $this->cache->setField( 0, $pageID, $pageProperties );
}
}
throw new Exception( __METHOD__ . '() is deprecated and does nothing' );
}
- /**
- * Handle the form submission if everything validated properly
- *
- * @deprecated since 1.31, use PreferencesFactory
- *
- * @param array $formData
- * @param HTMLForm $form
- * @return bool|Status|string
- */
- public static function tryFormSubmit( $formData, $form ) {
- $preferencesFactory = self::getDefaultPreferencesFactory();
- return $preferencesFactory->legacySaveFormData( $formData, $form );
- }
-
- /**
- * @param array $formData
- * @param HTMLForm $form
- * @return Status
- */
- public static function tryUISubmit( $formData, $form ) {
- $preferencesFactory = self::getDefaultPreferencesFactory();
- return $preferencesFactory->legacySubmitForm( $formData, $form );
- }
-
/**
* Get a list of all time zones
* @param Language $language Language used for the localized names
return []; // Return empty result
}
- $hasNamespace = $this->extractNamespace( $search );
- if ( $hasNamespace ) {
- list( $namespace, $search ) = $hasNamespace;
- $namespaces = [ $namespace ];
- } else {
- $namespaces = $this->validateNamespaces( $namespaces );
- Hooks::run( 'PrefixSearchExtractNamespace', [ &$namespaces, &$search ] );
+ $hasNamespace = SearchEngine::parseNamespacePrefixes( $search, false, true );
+ if ( $hasNamespace !== false ) {
+ list( $search, $namespaces ) = $hasNamespace;
}
return $this->searchBackend( $namespaces, $search, $limit, $offset );
}
- /**
- * Figure out if given input contains an explicit namespace.
- *
- * @param string $input
- * @return false|array Array of namespace and remaining text, or false if no namespace given.
- */
- protected function extractNamespace( $input ) {
- if ( strpos( $input, ':' ) === false ) {
- return false;
- }
-
- // Namespace prefix only
- $title = Title::newFromText( $input . 'Dummy' );
- if (
- $title &&
- $title->getText() === 'Dummy' &&
- !$title->inNamespace( NS_MAIN ) &&
- !$title->isExternal()
- ) {
- return [ $title->getNamespace(), '' ];
- }
-
- // Namespace prefix with additional input
- $title = Title::newFromText( $input );
- if (
- $title &&
- !$title->inNamespace( NS_MAIN ) &&
- !$title->isExternal()
- ) {
- // getText provides correct capitalization
- return [ $title->getNamespace(), $title->getText() ];
- }
-
- return false;
- }
-
/**
* Do a prefix search for all possible variants of the prefix
* @param string $search
$search->setFeatureData( 'rewrite', (bool)$params['enablerewrites'] );
$search->setFeatureData( 'interwiki', (bool)$interwiki );
- $query = $search->transformSearchTerm( $query );
- $query = $search->replacePrefixes( $query );
+ $nquery = $search->transformSearchTerm( $query );
+ if ( $nquery !== $query ) {
+ $query = $nquery;
+ wfDeprecated( 'SearchEngine::transformSearchTerm() (overridden by ' .
+ get_class( $search ) . ')', '1.32' );
+ }
+ $nquery = $search->replacePrefixes( $query );
+ if ( $nquery !== $query ) {
+ $query = $nquery;
+ wfDeprecated( 'SearchEngine::replacePrefixes() (overridden by ' .
+ get_class( $search ) . ')', '1.32' );
+ }
// Perform the actual search
if ( $what == 'text' ) {
$matches = $search->searchText( $query );
protected $repoName;
/** @var Closure */
protected $dbHandleFunc;
- /** @var ProcessCacheLRU */
+ /** @var MapCacheLRU */
protected $resolvedPathCache;
/** @var DBConnRef[] */
protected $dbs;
$this->backend = $config['backend'];
$this->repoName = $config['repoName'];
$this->dbHandleFunc = $config['dbHandleFactory'];
- $this->resolvedPathCache = new ProcessCacheLRU( 100 );
+ $this->resolvedPathCache = new MapCacheLRU( 100 );
}
/**
// @TODO: batching
$resolved = [];
foreach ( $paths as $i => $path ) {
- if ( !$latest && $this->resolvedPathCache->has( $path, 'target', 10 ) ) {
- $resolved[$i] = $this->resolvedPathCache->get( $path, 'target' );
+ if ( !$latest && $this->resolvedPathCache->hasField( $path, 'target', 10 ) ) {
+ $resolved[$i] = $this->resolvedPathCache->getField( $path, 'target' );
continue;
}
continue;
}
$resolved[$i] = $this->getPathForSHA1( $sha1 );
- $this->resolvedPathCache->set( $path, 'target', $resolved[$i] );
+ $this->resolvedPathCache->setField( $path, 'target', $resolved[$i] );
} elseif ( $container === "{$this->repoName}-deleted" ) {
$name = basename( $path ); // <hash>.<ext>
$sha1 = substr( $name, 0, strpos( $name, '.' ) ); // ignore extension
$resolved[$i] = $this->getPathForSHA1( $sha1 );
- $this->resolvedPathCache->set( $path, 'target', $resolved[$i] );
+ $this->resolvedPathCache->setField( $path, 'target', $resolved[$i] );
} else {
$resolved[$i] = $path;
}
function __construct( $localInfo, $foreignInfo ) {
$this->localInfo = $localInfo;
$this->foreignInfo = $foreignInfo;
- $this->cache = new ProcessCacheLRU( self::MAX_CACHE_SIZE );
+ $this->cache = new MapCacheLRU( self::MAX_CACHE_SIZE );
}
/**
&& empty( $options['latest'] )
) {
$time = $options['time'] ?? '';
- if ( $this->cache->has( $dbkey, $time, 60 ) ) {
- return $this->cache->get( $dbkey, $time );
+ if ( $this->cache->hasField( $dbkey, $time, 60 ) ) {
+ return $this->cache->getField( $dbkey, $time );
}
$useCache = true;
} else {
$image = $image ?: false; // type sanity
# Cache file existence or non-existence
if ( $useCache && ( !$image || $image->isCacheable() ) ) {
- $this->cache->set( $dbkey, $time, $image );
+ $this->cache->setField( $dbkey, $time, $image );
}
return $image;
protected function __construct( $wiki, $readOnlyReason ) {
$this->wiki = $wiki;
$this->readOnlyReason = $readOnlyReason;
- $this->cache = new ProcessCacheLRU( 10 );
+ $this->cache = new MapCacheLRU( 10 );
}
/**
$this->get( $type )->push( $jobs );
}
- if ( $this->cache->has( 'queues-ready', 'list' ) ) {
- $list = $this->cache->get( 'queues-ready', 'list' );
+ if ( $this->cache->hasField( 'queues-ready', 'list' ) ) {
+ $list = $this->cache->getField( 'queues-ready', 'list' );
if ( count( array_diff( array_keys( $jobsByType ), $list ) ) ) {
$this->cache->clear( 'queues-ready' );
}
}
} else { // any job in the "default" jobs types
if ( $flags & self::USE_CACHE ) {
- if ( !$this->cache->has( 'queues-ready', 'list', self::PROC_CACHE_TTL ) ) {
- $this->cache->set( 'queues-ready', 'list', $this->getQueuesWithJobs() );
+ if ( !$this->cache->hasField( 'queues-ready', 'list', self::PROC_CACHE_TTL ) ) {
+ $this->cache->setField( 'queues-ready', 'list', $this->getQueuesWithJobs() );
}
- $types = $this->cache->get( 'queues-ready', 'list' );
+ $types = $this->cache->getField( 'queues-ready', 'list' );
} else {
$types = $this->getQueuesWithJobs();
}
namespace MediaWiki\Preferences;
-use CentralIdLookup;
use Config;
use DateTime;
use DateTimeZone;
use SpecialPreferences;
use Status;
use Title;
+use UnexpectedValueException;
use User;
use UserGroupMembership;
use Xml;
$this->logger = new NullLogger();
}
- /**
- * @return callable[]
- */
- protected function getSaveFilters() {
- // Wrap intval() so that we can pass it multiple parameters and treat all filters the same.
- $intvalFilter = function ( $value, $alldata ) {
- return intval( $value );
- };
- return [
- 'timecorrection' => [ $this, 'filterTimezoneInput' ],
- 'rclimit' => $intvalFilter,
- 'wllimit' => $intvalFilter,
- 'searchlimit' => $intvalFilter,
- ];
- }
-
/**
* @inheritDoc
*/
$disable = !$user->isAllowed( 'editmyoptions' );
$defaultOptions = User::getDefaultOptions();
+ $userOptions = $user->getOptions();
+ $this->applyFilters( $userOptions, $defaultPreferences, 'filterForForm' );
# # Prod in defaults from the user
foreach ( $defaultPreferences as $name => &$info ) {
- $prefFromUser = $this->getOptionFromUser( $name, $info, $user );
+ $prefFromUser = $this->getOptionFromUser( $name, $info, $userOptions );
if ( $disable && !in_array( $name, $this->getSaveBlacklist() ) ) {
$info['disabled'] = 'disabled';
}
*
* @param string $name
* @param array $info
- * @param User $user
+ * @param array $userOptions
* @return array|string
*/
- protected function getOptionFromUser( $name, $info, User $user ) {
- $val = $user->getOption( $name );
+ protected function getOptionFromUser( $name, $info, array $userOptions ) {
+ $val = $userOptions[$name] ?? null;
// Handling for multiselect preferences
if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
$val = [];
foreach ( $options as $value ) {
- if ( $user->getOption( "$prefix$value" ) ) {
+ if ( $userOptions["$prefix$value"] ?? false ) {
$val[] = $value;
}
}
foreach ( $columns as $column ) {
foreach ( $rows as $row ) {
- if ( $user->getOption( "$prefix$column-$row" ) ) {
+ if ( $userOptions["$prefix$column-$row"] ?? false ) {
$val[] = "$column-$row";
}
}
];
if ( $this->config->get( 'EnableUserEmailBlacklist' ) ) {
- $lookup = CentralIdLookup::factory();
- $ids = $user->getOption( 'email-blacklist', [] );
- $names = $ids ? $lookup->namesFromCentralIds( $ids, $user ) : [];
-
$defaultPreferences['email-blacklist'] = [
'type' => 'usersmultiselect',
'label-message' => 'email-blacklist-label',
'section' => 'personal/email',
- 'default' => implode( "\n", $names ),
'disabled' => $disableEmailPrefs,
+ 'filter' => MultiUsernameFilter::class,
];
}
}
'size' => 20,
'section' => 'rendering/timeoffset',
'id' => 'wpTimeCorrection',
+ 'filter' => TimezoneFilter::class,
];
}
'label-message' => 'recentchangescount',
'help-message' => 'prefs-help-recentchangescount',
'section' => 'rc/displayrc',
+ 'filter' => IntvalFilter::class,
];
$defaultPreferences['usenewrc'] = [
'type' => 'toggle',
'label-message' => 'prefs-watchlist-edits',
'help' => $context->msg( 'prefs-watchlist-edits-max' )->escaped(),
'section' => 'watchlist/displaywatchlist',
+ 'filter' => IntvalFilter::class,
];
$defaultPreferences['extendwatchlist'] = [
'type' => 'toggle',
# Used message keys: 'accesskey-preferences-save', 'tooltip-preferences-save'
$htmlForm->setSubmitTooltip( 'preferences-save' );
$htmlForm->setSubmitID( 'prefcontrol' );
- $htmlForm->setSubmitCallback( function ( array $formData, HTMLForm $form ) {
- return $this->submitForm( $formData, $form );
- } );
+ $htmlForm->setSubmitCallback(
+ function ( array $formData, HTMLForm $form ) use ( $formDescriptor ) {
+ return $this->submitForm( $formData, $form, $formDescriptor );
+ }
+ );
return $htmlForm;
}
return $opt;
}
- /**
- * @param string $tz
- * @param array $alldata
- * @return string
- */
- protected function filterTimezoneInput( $tz, array $alldata ) {
- $data = explode( '|', $tz, 3 );
- switch ( $data[0] ) {
- case 'ZoneInfo':
- $valid = false;
-
- if ( count( $data ) === 3 ) {
- // Make sure this timezone exists
- try {
- new DateTimeZone( $data[2] );
- // If the constructor didn't throw, we know it's valid
- $valid = true;
- } catch ( Exception $e ) {
- // Not a valid timezone
- }
- }
-
- if ( !$valid ) {
- // If the supplied timezone doesn't exist, fall back to the encoded offset
- return 'Offset|' . intval( $tz[1] );
- }
- return $tz;
- case 'System':
- return $tz;
- default:
- $data = explode( ':', $tz, 2 );
- if ( count( $data ) == 2 ) {
- $data[0] = intval( $data[0] );
- $data[1] = intval( $data[1] );
- $minDiff = abs( $data[0] ) * 60 + $data[1];
- if ( $data[0] < 0 ) {
- $minDiff = - $minDiff;
- }
- } else {
- $minDiff = intval( $data[0] ) * 60;
- }
-
- # Max is +14:00 and min is -12:00, see:
- # https://en.wikipedia.org/wiki/Timezone
- $minDiff = min( $minDiff, 840 ); # 14:00
- $minDiff = max( $minDiff, -720 ); # -12:00
- return 'Offset|' . $minDiff;
- }
- }
-
/**
* Handle the form submission if everything validated properly
*
* @param array $formData
* @param HTMLForm $form
+ * @param array[] $formDescriptor
* @return bool|Status|string
*/
- protected function saveFormData( $formData, HTMLForm $form ) {
+ protected function saveFormData( $formData, HTMLForm $form, array $formDescriptor ) {
+ /** @var \User $user */
$user = $form->getModifiedUser();
$hiddenPrefs = $this->config->get( 'HiddenPrefs' );
$result = true;
}
// Filter input
- foreach ( array_keys( $formData ) as $name ) {
- $filters = $this->getSaveFilters();
- if ( isset( $filters[$name] ) ) {
- $formData[$name] = call_user_func( $filters[$name], $formData[$name], $formData );
- }
- }
+ $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
// Fortunately, the realname field is MUCH simpler
// (not really "private", but still shouldn't be edited without permission)
}
/**
- * DO NOT USE. Temporary function to punch hole for the Preferences class.
- *
- * @deprecated since 1.31, its inception
+ * Applies filters to preferences either before or after form usage
*
- * @param array $formData
- * @param HTMLForm $form
- * @return bool|Status|string
+ * @param array &$preferences
+ * @param array $formDescriptor
+ * @param string $verb Name of the filter method to call, either 'filterFromForm' or
+ * 'filterForForm'
*/
- public function legacySaveFormData( $formData, HTMLForm $form ) {
- return $this->saveFormData( $formData, $form );
+ protected function applyFilters( array &$preferences, array $formDescriptor, $verb ) {
+ foreach ( $formDescriptor as $preference => $desc ) {
+ if ( !isset( $desc['filter'] ) || !isset( $preferences[$preference] ) ) {
+ continue;
+ }
+ $filterDesc = $desc['filter'];
+ if ( $filterDesc instanceof Filter ) {
+ $filter = $filterDesc;
+ } elseif ( class_exists( $filterDesc ) ) {
+ $filter = new $filterDesc();
+ } elseif ( is_callable( $filterDesc ) ) {
+ $filter = $filterDesc();
+ } else {
+ throw new UnexpectedValueException(
+ "Unrecognized filter type for preference '$preference'"
+ );
+ }
+ $preferences[$preference] = $filter->$verb( $preferences[$preference] );
+ }
}
/**
*
* @param array $formData
* @param HTMLForm $form
+ * @param array $formDescriptor
* @return Status
*/
- protected function submitForm( array $formData, HTMLForm $form ) {
- $res = $this->saveFormData( $formData, $form );
+ protected function submitForm( array $formData, HTMLForm $form, array $formDescriptor ) {
+ $res = $this->saveFormData( $formData, $form, $formDescriptor );
if ( $res === true ) {
$context = $form->getContext();
return ( $res === true ? Status::newGood() : $res );
}
- /**
- * DO NOT USE. Temporary function to punch hole for the Preferences class.
- *
- * @deprecated since 1.31, its inception
- *
- * @param array $formData
- * @param HTMLForm $form
- * @return Status
- */
- public function legacySubmitForm( array $formData, HTMLForm $form ) {
- return $this->submitForm( $formData, $form );
- }
-
/**
* Get a list of all time zones
* @param Language $language Language used for the localized names
--- /dev/null
+<?php
+/**
+ * This program 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.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Preferences;
+
+/**
+ * Base interface for user preference flters that work as a middleware between
+ * storage and interface.
+ */
+interface Filter {
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ public function filterForForm( $value );
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ public function filterFromForm( $value );
+}
--- /dev/null
+<?php
+/**
+ * This program 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.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Preferences;
+
+class IntvalFilter implements Filter {
+
+ /**
+ * @inheritDoc
+ */
+ public function filterForForm( $value ) {
+ return $value;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function filterFromForm( $value ) {
+ return intval( $value );
+ }
+}
--- /dev/null
+<?php
+/**
+ * This program 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.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Preferences;
+
+use CentralIdLookup;
+
+class MultiUsernameFilter implements Filter {
+ /**
+ * @var CentralIdLookup|null
+ */
+ private $lookup;
+ /** @var CentralIdLookup|int User querying central usernames or one of the audience constants */
+ private $userOrAudience;
+
+ /**
+ * @param CentralIdLookup|null $lookup
+ * @param int $userOrAudience
+ */
+ public function __construct( CentralIdLookup $lookup = null,
+ $userOrAudience = CentralIdLookup::AUDIENCE_PUBLIC
+ ) {
+ $this->lookup = $lookup;
+ $this->userOrAudience = $userOrAudience;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function filterFromForm( $names ) {
+ $names = trim( $names );
+ if ( $names !== '' ) {
+ $names = preg_split( '/\n/', $names, -1, PREG_SPLIT_NO_EMPTY );
+ $ids = $this->getLookup()->centralIdsFromNames( $names, $this->userOrAudience );
+ if ( $ids ) {
+ return implode( "\n", $ids );
+ }
+ }
+ // If the user list is empty, it should be null (don't save) rather than an empty string
+ return null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function filterForForm( $value ) {
+ $ids = self::splitIds( $value );
+ $names = $ids ? $this->getLookup()->namesFromCentralIds( $ids, $this->userOrAudience ) : [];
+ return implode( "\n", $names );
+ }
+
+ /**
+ * Splits a newline separated list of user ids into a
+ *
+ * @param string $str
+ * @return int[]
+ */
+ public static function splitIds( $str ) {
+ return array_map( 'intval', preg_split( '/\n/', $str, -1, PREG_SPLIT_NO_EMPTY ) );
+ }
+
+ /**
+ * @return CentralIdLookup
+ */
+ private function getLookup() {
+ $this->lookup = $this->lookup ?? CentralIdLookup::factory();
+ return $this->lookup;
+ }
+}
--- /dev/null
+<?php
+/**
+ * This program 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.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Preferences;
+
+use DateTimeZone;
+use Exception;
+
+class TimezoneFilter implements Filter {
+
+ /**
+ * @inheritDoc
+ */
+ public function filterForForm( $value ) {
+ return $value;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function filterFromForm( $tz ) {
+ $data = explode( '|', $tz, 3 );
+ switch ( $data[0] ) {
+ case 'ZoneInfo':
+ $valid = false;
+
+ if ( count( $data ) === 3 ) {
+ // Make sure this timezone exists
+ try {
+ new DateTimeZone( $data[2] );
+ // If the constructor didn't throw, we know it's valid
+ $valid = true;
+ } catch ( Exception $e ) {
+ // Not a valid timezone
+ }
+ }
+
+ if ( !$valid ) {
+ // If the supplied timezone doesn't exist, fall back to the encoded offset
+ return 'Offset|' . intval( $tz[1] );
+ }
+ return $tz;
+ case 'System':
+ return $tz;
+ default:
+ $data = explode( ':', $tz, 2 );
+ if ( count( $data ) == 2 ) {
+ $data[0] = intval( $data[0] );
+ $data[1] = intval( $data[1] );
+ $minDiff = abs( $data[0] ) * 60 + $data[1];
+ if ( $data[0] < 0 ) {
+ $minDiff = - $minDiff;
+ }
+ } else {
+ $minDiff = intval( $data[0] ) * 60;
+ }
+
+ # Max is +14:00 and min is -12:00, see:
+ # https://en.wikipedia.org/wiki/Timezone
+ # 14:00
+ $minDiff = min( $minDiff, 840 );
+ # -12:00
+ $minDiff = max( $minDiff, -720 );
+ return 'Offset|' . $minDiff;
+ }
+ }
+}
use Composer\Spdx\SpdxLicenses;
use JsonSchema\Validator;
+use Seld\JsonLint\JsonParser;
+use Seld\JsonLint\ParsingException;
/**
* @since 1.29
'The spdx-licenses library cannot be found, please install it through composer.'
);
return false;
+ } elseif ( !class_exists( JsonParser::class ) ) {
+ call_user_func( $this->missingDepCallback,
+ 'The JSON lint library cannot be found, please install it through composer.'
+ );
}
return true;
* @throws ExtensionJsonValidationError on any failure
*/
public function validate( $path ) {
- $data = json_decode( file_get_contents( $path ) );
- if ( !is_object( $data ) ) {
+ $contents = file_get_contents( $path );
+ $jsonParser = new JsonParser();
+ try {
+ $data = $jsonParser->parse( $contents, JsonParser::DETECT_KEY_CONFLICTS );
+ } catch ( ParsingException $e ) {
+ if ( $e instanceof \Seld\JsonLint\DuplicateKeyException ) {
+ throw new ExtensionJsonValidationError( $e->getMessage() );
+ }
throw new ExtensionJsonValidationError( "$path is not valid JSON" );
}
}
break;
case 'extensions':
- case 'skin':
+ case 'skins':
foreach ( $values as $dependency => $constraint ) {
$extError = $this->handleExtensionDependency(
$dependency, $constraint, $extension, $dependencyType
* @param string $dependencyName The name of the dependency
* @param string $constraint The required version constraint for this dependency
* @param string $checkedExt The Extension, which depends on this dependency
- * @param string $type Either 'extension' or 'skin'
+ * @param string $type Either 'extensions' or 'skins'
* @return bool|array false for no errors, or an array of info
*/
private function handleExtensionDependency( $dependencyName, $constraint, $checkedExt,
* @ingroup Search
* @since 1.23
*/
-class SearchDatabase extends SearchEngine {
+abstract class SearchDatabase extends SearchEngine {
/**
* @var IDatabase Slave database for reading from for results
*/
}
}
+ /**
+ * @param string $term
+ * @return SearchResultSet|Status|null
+ */
+ final public function doSearchText( $term ) {
+ return $this->doSearchTextInDB( $this->extractNamespacePrefix( $term ) );
+ }
+
+ /**
+ * Perform a full text search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ abstract protected function doSearchTextInDB( $term );
+
+ /**
+ * @param string $term
+ * @return SearchResultSet|null
+ */
+ final public function doSearchTitle( $term ) {
+ return $this->doSearchTitleInDB( $this->extractNamespacePrefix( $term ) );
+ }
+
+ /**
+ * Perform a title-only search query and return a result set.
+ *
+ * @param string $term Raw search term
+ * @return SqlSearchResultSet
+ */
+ abstract protected function doSearchTitleInDB( $term );
+
/**
* Return a 'cleaned up' search string
*
$lc = $this->legalSearchChars( self::CHARS_ALL );
return trim( preg_replace( "/[^{$lc}]/", " ", $text ) );
}
+
+ /**
+ * Extract the optional namespace prefix and set self::namespaces
+ * accordingly and return the query string
+ * @param string $term
+ * @return string the query string without any namespace prefix
+ */
+ final protected function extractNamespacePrefix( $term ) {
+ $queryAndNs = self::parseNamespacePrefixes( $term );
+ if ( $queryAndNs === false ) {
+ return $term;
+ }
+ $this->namespaces = $queryAndNs[1];
+ return $queryAndNs[0];
+ }
}
* search=test&prefix=Main_Page/Archive -> test prefix:Main Page/Archive
* @param string $term
* @return string
+ * @deprecated since 1.32 this should now be handled internally by the
+ * search engine
*/
public function transformSearchTerm( $term ) {
return $term;
* or namespace names and set the list of namespaces
* of this class accordingly.
*
+ * @deprecated since 1.32; should be handled internally by the search engine
* @param string $query
* @return string
*/
function replacePrefixes( $query ) {
- $queryAndNs = self::parseNamespacePrefixes( $query );
- if ( $queryAndNs === false ) {
- return $query;
- }
- $this->namespaces = $queryAndNs[1];
- return $queryAndNs[0];
+ return $query;
}
/**
* or namespace names
*
* @param string $query
+ * @param bool $withAllKeyword activate support of the "all:" keyword and its
+ * translations to activate searching on all namespaces.
+ * @param bool $withPrefixSearchExtractNamespaceHook call the PrefixSearchExtractNamespace hook
+ * if classic namespace identification did not match.
* @return false|array false if no namespace was extracted, an array
* with the parsed query at index 0 and an array of namespaces at index
* 1 (or null for all namespaces).
- */
- public static function parseNamespacePrefixes( $query ) {
+ * @throws FatalError
+ * @throws MWException
+ */
+ public static function parseNamespacePrefixes(
+ $query,
+ $withAllKeyword = true,
+ $withPrefixSearchExtractNamespaceHook = false
+ ) {
global $wgContLang;
$parsed = $query;
return false;
}
$extractedNamespace = null;
- $allkeywords = [];
-
- $allkeywords[] = wfMessage( 'searchall' )->inContentLanguage()->text() . ":";
- // force all: so that we have a common syntax for all the wikis
- if ( !in_array( 'all:', $allkeywords ) ) {
- $allkeywords[] = 'all:';
- }
$allQuery = false;
- foreach ( $allkeywords as $kw ) {
- if ( strncmp( $query, $kw, strlen( $kw ) ) == 0 ) {
- $extractedNamespace = null;
- $parsed = substr( $query, strlen( $kw ) );
- $allQuery = true;
- break;
+ if ( $withAllKeyword ) {
+ $allkeywords = [];
+
+ $allkeywords[] = wfMessage( 'searchall' )->inContentLanguage()->text() . ":";
+ // force all: so that we have a common syntax for all the wikis
+ if ( !in_array( 'all:', $allkeywords ) ) {
+ $allkeywords[] = 'all:';
+ }
+
+ foreach ( $allkeywords as $kw ) {
+ if ( strncmp( $query, $kw, strlen( $kw ) ) == 0 ) {
+ $extractedNamespace = null;
+ $parsed = substr( $query, strlen( $kw ) );
+ $allQuery = true;
+ break;
+ }
}
}
if ( !$allQuery && strpos( $query, ':' ) !== false ) {
- // TODO: should we unify with PrefixSearch::extractNamespace ?
$prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) );
$index = $wgContLang->getNsIndex( $prefix );
if ( $index !== false ) {
$extractedNamespace = [ $index ];
$parsed = substr( $query, strlen( $prefix ) + 1 );
+ } elseif ( $withPrefixSearchExtractNamespaceHook ) {
+ $hookNamespaces = [ NS_MAIN ];
+ $hookQuery = $query;
+ Hooks::run( 'PrefixSearchExtractNamespace', [ &$hookNamespaces, &$hookQuery ] );
+ if ( $hookQuery !== $query ) {
+ $parsed = $hookQuery;
+ $extractedNamespace = $hookNamespaces;
+ } else {
+ return false;
+ }
} else {
return false;
}
}
- if ( trim( $parsed ) == '' ) {
- $parsed = $query; // prefix was the whole query
- }
-
return [ $parsed, $extractedNamespace ];
}
* @return string Simplified search string
*/
protected function normalizeNamespaces( $search ) {
- // Find a Title which is not an interwiki and is in NS_MAIN
- $title = Title::newFromText( $search );
- $ns = $this->namespaces;
- if ( $title && !$title->isExternal() ) {
- $ns = [ $title->getNamespace() ];
- $search = $title->getText();
- if ( $ns[0] == NS_MAIN ) {
- $ns = $this->namespaces; // no explicit prefix, use default namespaces
- Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
- }
- } else {
- $title = Title::newFromText( $search . 'Dummy' );
- if ( $title && $title->getText() == 'Dummy'
- && $title->getNamespace() != NS_MAIN
- && !$title->isExternal()
- ) {
- $ns = [ $title->getNamespace() ];
- $search = '';
- } else {
- Hooks::run( 'PrefixSearchExtractNamespace', [ &$ns, &$search ] );
- }
+ $queryAndNs = self::parseNamespacePrefixes( $search, false, true );
+ if ( $queryAndNs !== false ) {
+ $this->setNamespaces( $queryAndNs[1] );
+ return $queryAndNs[0];
}
-
- $ns = array_map( function ( $space ) {
- return $space == NS_MEDIA ? NS_FILE : $space;
- }, $ns );
-
- $this->setNamespaces( $ns );
return $search;
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchText( $term ) {
+ protected function doSearchTextInDB( $term ) {
$resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) );
return new SqlSearchResultSet( $resultSet, $this->searchTerms );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchTitle( $term ) {
+ protected function doSearchTitleInDB( $term ) {
$resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) );
return new SqlSearchResultSet( $resultSet, $this->searchTerms );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchText( $term ) {
+ protected function doSearchTextInDB( $term ) {
return $this->searchInternal( $term, true );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchTitle( $term ) {
+ protected function doSearchTitleInDB( $term ) {
return $this->searchInternal( $term, false );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchText( $term ) {
+ protected function doSearchTextInDB( $term ) {
if ( $term == '' ) {
return new SqlSearchResultSet( false, '' );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchTitle( $term ) {
+ protected function doSearchTitleInDB( $term ) {
if ( $term == '' ) {
return new SqlSearchResultSet( false, '' );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchTitle( $term ) {
+ protected function doSearchTitleInDB( $term ) {
$q = $this->searchQuery( $term, 'titlevector', 'page_title' );
$olderror = error_reporting( E_ERROR );
$resultSet = $this->db->query( $q, 'SearchPostgres', true );
return new SqlSearchResultSet( $resultSet, $this->searchTerms );
}
- protected function doSearchText( $term ) {
+ protected function doSearchTextInDB( $term ) {
$q = $this->searchQuery( $term, 'textvector', 'old_text' );
$olderror = error_reporting( E_ERROR );
$resultSet = $this->db->query( $q, 'SearchPostgres', true );
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchText( $term ) {
+ protected function doSearchTextInDB( $term ) {
return $this->searchInternal( $term, true );
}
* @param string $term Raw search term
* @return SqlSearchResultSet
*/
- protected function doSearchTitle( $term ) {
+ protected function doSearchTitleInDB( $term ) {
return $this->searchInternal( $term, false );
}
* @ingroup SpecialPage
*/
use MediaWiki\MediaWikiServices;
+use MediaWiki\Preferences\MultiUsernameFilter;
/**
* A special page that allows users to send e-mails to other users
}
if ( $sender !== null ) {
- $blacklist = $target->getOption( 'email-blacklist', [] );
+ $blacklist = $target->getOption( 'email-blacklist', '' );
if ( $blacklist ) {
+ $blacklist = MultiUsernameFilter::splitIds( $blacklist );
$lookup = CentralIdLookup::factory();
$senderId = $lookup->centralIdFromLocalUser( $sender );
if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
$request = $this->getRequest();
list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' );
$this->mPrefix = $request->getVal( 'prefix', '' );
+ if ( $this->mPrefix !== '' ) {
+ $this->setExtraParam( 'prefix', $this->mPrefix );
+ }
$user = $this->getUser();
$search->setLimitOffset( $this->limit, $this->offset );
$search->setNamespaces( $this->namespaces );
$search->prefix = $this->mPrefix;
- $term = $search->transformSearchTerm( $term );
Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $search ] );
if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) {
$showSuggestion = $title === null || !$title->isKnown();
$search->setShowSuggestion( $showSuggestion );
- // fetch search results
+ $rewritten = $search->transformSearchTerm( $term );
+ if ( $rewritten !== $term ) {
+ $term = $rewritten;
+ wfDeprecated( 'SearchEngine::transformSearchTerm() (overridden by ' .
+ get_class( $search ) . ')', '1.32' );
+ }
+
$rewritten = $search->replacePrefixes( $term );
+ if ( $rewritten !== $term ) {
+ wfDeprecated( 'SearchEngine::replacePrefixes() (overridden by ' .
+ get_class( $search ) . ')', '1.32' );
+ }
+ // fetch search results
$titleMatches = $search->searchTitle( $rewritten );
$textMatches = $search->searchText( $rewritten );
);
}
+ if ( $this->mPrefix !== '' ) {
+ $subtitle = $this->msg( 'search-filter-title-prefix' )->plaintextParams( $this->mPrefix );
+ $params = $this->powerSearchOptions();
+ unset( $params['prefix'] );
+ $params += [
+ 'search' => $term,
+ 'fulltext' => 1,
+ ];
+
+ $subtitle .= ' (';
+ $subtitle .= Xml::element(
+ 'a',
+ [
+ 'href' => $this->getPageTitle()->getLocalURL( $params ),
+ 'title' => $this->msg( 'search-filter-title-prefix-reset' ),
+ ],
+ $this->msg( 'search-filter-title-prefix-reset' )
+ );
+ $subtitle .= ')';
+ $out->setSubtitle( $subtitle );
+ }
+
$out->addJsConfigVars( [ 'searchTerm' => $term ] );
$out->addModules( 'mediawiki.special.search' );
$out->addModuleStyles( [
$this->extraParams[$key] = $value;
}
+ /**
+ * The prefix value send to Special:Search using the 'prefix' URI param
+ * It means that the user is willing to search for pages whose titles start with
+ * this prefix value.
+ * (Used by the InputBox extension)
+ *
+ * @return string
+ */
+ public function getPrefix() {
+ return $this->mPrefix;
+ }
+
protected function getGroupName() {
return 'pages';
}
}
}
- // Convert the email blacklist from a new line delimited string
- // to an array of ids.
- if ( isset( $data['email-blacklist'] ) && $data['email-blacklist'] ) {
- $data['email-blacklist'] = array_map( 'intval', explode( "\n", $data['email-blacklist'] ) );
- }
-
foreach ( $data as $property => $value ) {
$this->mOptionOverrides[$property] = $value;
$this->mOptions[$property] = $value;
// Not using getOptions(), to keep hidden preferences in database
$saveOptions = $this->mOptions;
- // Convert usernames to ids.
- if ( isset( $this->mOptions['email-blacklist'] ) ) {
- if ( $this->mOptions['email-blacklist'] ) {
- $value = $this->mOptions['email-blacklist'];
- // Email Blacklist may be an array of ids or a string of new line
- // delimnated user names.
- if ( is_array( $value ) ) {
- $ids = array_filter( $value, 'is_numeric' );
- } else {
- $lookup = CentralIdLookup::factory();
- $ids = $lookup->centralIdsFromNames( explode( "\n", $value ), $this );
- }
- $this->mOptions['email-blacklist'] = $ids;
- $saveOptions['email-blacklist'] = implode( "\n", $this->mOptions['email-blacklist'] );
- } else {
- // If the blacklist is empty, set it to null rather than an empty string.
- $this->mOptions['email-blacklist'] = null;
- }
- }
-
// Allow hooks to abort, for instance to save to a global profile.
// Reset options to default state before saving.
if ( !Hooks::run( 'UserSaveOptions', [ $this, &$saveOptions ] ) ) {
$html .= $layout;
+ if ( $this->specialSearch->getPrefix() !== '' ) {
+ $html .= Html::hidden( 'prefix', $this->specialSearch->getPrefix() );
+ }
+
if ( $totalResults > 0 && $offset < $totalResults ) {
$html .= Xml::tags(
'div',
"difference-missing-revision": "{{PLURAL:$2|One revision|$2 revisions}} of this difference ($1) {{PLURAL:$2|was|were}} not found.\n\nThis is usually caused by following an outdated diff link to a page that has been deleted.\nDetails can be found in the [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} deletion log].",
"search-summary": "",
"searchresults": "Search results",
+ "search-filter-title-prefix": "Only searching in pages whose title starts with \"$1\"",
+ "search-filter-title-prefix-reset": "Search all pages",
"searchresults-title": "Search results for \"$1\"",
"titlematches": "Page title matches",
"textmatches": "Page text matches",
"speciallogtitlelabel": "Target (title or {{ns:user}}:username for user):",
"log": "Logs",
"logeventslist-submit": "Show",
- "logeventslist-more-filters": "More filters:",
+ "logeventslist-more-filters": "Show additional logs:",
"logeventslist-patrol-log": "Patrol log",
"logeventslist-tag-log": "Tag log",
"all-logs-page": "All public logs",
"difference-missing-revision": "Text displayed when the requested revision does not exist using a diff link.\n\nExample: [{{canonicalurl:Project:News|diff=426850&oldid=99999999}} Diff with invalid revision#]\n\nParameters:\n* $1 - the list of missing revisions IDs\n* $2 - the number of items in $1 (one or two)",
"search-summary": "{{doc-specialpagesummary|search}}",
"searchresults": "This is the title of the page that contains the results of a search.\n\n{{Identical|Search results}}",
+ "search-filter-title-prefix": "Subtitle added to indicate that the user is filtering for pages whose title starts with $1, \n* $1 - the title prefix",
+ "search-filter-title-prefix-reset": "Appears next to {{msg-mw|search-filter-title-prefix}} as a link to let users reset the prefix filter",
"searchresults-title": "Appears as page title in the html header of the search result special page.\n\nParameters:\n* $1 - the search term",
"titlematches": "Used as section header in [[Special:Search]].\n\nThis message is followed by search results.",
"textmatches": "When displaying search results",
"speciallogtitlelabel": "Used in [[Special:Log]] as a label for an input field with which the log can be filtered. This filter selects for pages or users on which a log action was performed.",
"log": "{{doc-special|Log}}\n{{Identical|Log}}",
"logeventslist-submit": "Submit button on [[Special:Log]]\n{{Identical|Show}}",
- "logeventslist-more-filters": "More filters label on [[Special:Log]]",
+ "logeventslist-more-filters": "Label on [[Special:Log]]. Some log types are hidden by default when viewing \"all\" logs, because they might be very verbose, and these options show them.",
"logeventslist-patrol-log": "Patrol log option label on [[Special:Log]]",
"logeventslist-tag-log": "Tag log option label on [[Special:Log]]",
"all-logs-page": "{{doc-logpage}}\nTitle of [[Special:Log]].",
wikif1|http://www.wikif1.org/$1|0|
wikihow|https://www.wikihow.com/$1|0|https://www.wikihow.com/api.php
wikinfo|http://wikinfo.co/English/index.php/$1|0|
-wikimedia|https://wikimediafoundation.org/wiki/$1|0|https://wikimediafoundation.org/w/api.php
+wikimedia|https://foundation.wikimedia.org/wiki/$1|0|https://foundation.wikimedia.org/w/api.php
wikinews|https://en.wikinews.org/wiki/$1|0|https://en.wikinews.org/w/api.php
wikipedia|https://en.wikipedia.org/wiki/$1|0|https://en.wikipedia.org/w/api.php
wikiquote|https://en.wikiquote.org/wiki/$1|0|https://en.wikiquote.org/w/api.php
('wikif1','http://www.wikif1.org/$1',0,''),
('wikihow','https://www.wikihow.com/$1',0,'https://www.wikihow.com/api.php'),
('wikinfo','http://wikinfo.co/English/index.php/$1',0,''),
-('wikimedia','https://wikimediafoundation.org/wiki/$1',0,'https://wikimediafoundation.org/w/api.php'),
+('wikimedia','https://foundation.wikimedia.org/wiki/$1',0,'https://foundation.wikimedia.org/w/api.php'),
('wikinews','https://en.wikinews.org/wiki/$1',0,'https://en.wikinews.org/w/api.php'),
('wikipedia','https://en.wikipedia.org/wiki/$1',0,'https://en.wikipedia.org/w/api.php'),
('wikiquote','https://en.wikiquote.org/wiki/$1',0,'https://en.wikiquote.org/w/api.php'),
left: 50%;
// Make sure the middle of the spinner is centered, rather than its left edge
margin-left: -3 * @rcfilters-spinner-size / 2;
-
- opacity: 0.8;
white-space: nowrap;
& .rcfilters-spinner-bounce,
&:before,
&:after {
content: '';
- display: inline-block;
+ background-color: @colorGray7;
+ display: block;
+ float: left;
width: @rcfilters-spinner-size;
height: @rcfilters-spinner-size;
- background-color: @colorGray12;
border-radius: 100%;
- .animation( rcfiltersBouncedelay 1.5s ease-in-out -0.16s infinite both );
+ .animation( rcfiltersBouncedelay 1600ms ease-in-out -160ms infinite both );
}
&:before {
- .animation-delay( -0.33s );
+ margin-right: 4px;
+ .animation-delay( -330ms );
}
&:after {
+ margin-left: 4px;
.animation-delay( 0s );
}
}
@-webkit-keyframes rcfiltersBouncedelay {
0%,
- 80%,
+ 50%, // equals 800ms
100% {
- -webkit-transform: scale( 0.7 );
- transform: scale( 0.7 );
+ -webkit-transform: scale( 0.625 );
}
- 40% {
- background-color: @colorGray10;
+ 20% { // equals 320ms
+ opacity: 0.87;
-webkit-transform: scale( 1 );
- transform: scale( 1 );
}
}
@-moz-keyframes rcfiltersBouncedelay {
0%,
- 80%,
+ 50%,
100% {
- -moz-transform: scale( 0.7 );
- transform: scale( 0.7 );
+ -moz-transform: scale( 0.625 );
}
- 40% {
- background-color: @colorGray10;
- -moz-transform: scale( 0.7 );
- transform: scale( 1 );
+ 20% {
+ opacity: 0.87;
+ -moz-transform: scale( 1 );
}
}
@keyframes rcfiltersBouncedelay {
0%,
- 80%,
+ 50%,
100% {
- transform: scale( 0.7 );
+ transform: scale( 0.625 );
}
- 40% {
- background-color: @colorGray10;
+ 20% { // equals 320ms
+ opacity: 0.87;
transform: scale( 1 );
}
}
--- /dev/null
+{
+ "name": "FooBar",
+ "name": "Test"
+}
$this->mUserMock->expects( $this->any() )
->method( 'getInstanceForUpdate' )->will( $this->returnValue( $this->mUserMock ) );
+ // Needs to return something
+ $this->mUserMock->method( 'getOptions' )
+ ->willReturn( [] );
+
// Create a new context
$this->mContext = new DerivativeContext( new RequestContext() );
$this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) );
}
);
+ /** @var DefaultPreferencesFactory $factory */
$factory = TestingAccessWrapper::newFromObject( $this->getPreferencesFactory() );
- $factory->saveFormData( $newOptions, $form );
+ $factory->saveFormData( $newOptions, $form, [] );
}
/**
--- /dev/null
+<?php
+/**
+ * This program 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.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\Preferences\IntvalFilter;
+use MediaWiki\Preferences\MultiUsernameFilter;
+use MediaWiki\Preferences\TimezoneFilter;
+
+/**
+ * @group Preferences
+ */
+class FiltersTest extends MediaWikiTestCase {
+ /**
+ * @covers MediaWiki\Preferences\IntvalFilter::filterFromForm()
+ * @covers MediaWiki\Preferences\IntvalFilter::filterForForm()
+ */
+ public function testIntvalFilter() {
+ $filter = new IntvalFilter();
+ self::assertSame( 0, $filter->filterFromForm( '0' ) );
+ self::assertSame( 3, $filter->filterFromForm( '3' ) );
+ self::assertSame( '123', $filter->filterForForm( '123' ) );
+ }
+
+ /**
+ * @covers MediaWiki\Preferences\TimezoneFilter::filterFromForm()
+ * @dataProvider provideTimezoneFilter
+ *
+ * @param string $input
+ * @param string $expected
+ */
+ public function testTimezoneFilter( $input, $expected ) {
+ $filter = new TimezoneFilter();
+ $result = $filter->filterFromForm( $input );
+ self::assertEquals( $expected, $result );
+ }
+
+ public function provideTimezoneFilter() {
+ return [
+ [ 'ZoneInfo', 'Offset|0' ],
+ [ 'ZoneInfo|bogus', 'Offset|0' ],
+ [ 'System', 'System' ],
+ [ '2:30', 'Offset|150' ],
+ ];
+ }
+
+ /**
+ * @covers MediaWiki\Preferences\MultiUsernameFilter::filterFromForm()
+ * @dataProvider provideMultiUsernameFilterFrom
+ *
+ * @param string $input
+ * @param string|null $expected
+ */
+ public function testMultiUsernameFilterFrom( $input, $expected ) {
+ $filter = $this->makeMultiUsernameFilter();
+ $result = $filter->filterFromForm( $input );
+ self::assertSame( $expected, $result );
+ }
+
+ public function provideMultiUsernameFilterFrom() {
+ return [
+ [ '', null ],
+ [ "\n\n\n", null ],
+ [ 'Foo', '1' ],
+ [ "\n\n\nFoo\nBar\n", "1\n2" ],
+ [ "Baz\nInvalid\nFoo", "3\n1" ],
+ [ "Invalid", null ],
+ [ "Invalid\n\n\nInvalid\n", null ],
+ ];
+ }
+
+ /**
+ * @covers MediaWiki\Preferences\MultiUsernameFilter::filterForForm()
+ * @dataProvider provideMultiUsernameFilterFor
+ *
+ * @param string $input
+ * @param string $expected
+ */
+ public function testMultiUsernameFilterFor( $input, $expected ) {
+ $filter = $this->makeMultiUsernameFilter();
+ $result = $filter->filterForForm( $input );
+ self::assertSame( $expected, $result );
+ }
+
+ public function provideMultiUsernameFilterFor() {
+ return [
+ [ '', '' ],
+ [ "\n", '' ],
+ [ '1', 'Foo' ],
+ [ "\n1\n\n2\666\n", "Foo\nBar" ],
+ [ "666\n667", '' ],
+ ];
+ }
+
+ private function makeMultiUsernameFilter() {
+ $userMapping = [
+ 'Foo' => 1,
+ 'Bar' => 2,
+ 'Baz' => 3,
+ ];
+ $flipped = array_flip( $userMapping );
+ $idLookup = self::getMockBuilder( CentralIdLookup::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'centralIdsFromNames', 'namesFromCentralIds' ] )
+ ->getMockForAbstractClass();
+
+ $idLookup->method( 'centralIdsFromNames' )
+ ->will( self::returnCallback( function ( $names ) use ( $userMapping ) {
+ $ids = [];
+ foreach ( $names as $name ) {
+ $ids[] = $userMapping[$name] ?? null;
+ }
+ return array_filter( $ids, 'is_numeric' );
+ } ) );
+ $idLookup->method( 'namesFromCentralIds' )
+ ->will( self::returnCallback( function ( $ids ) use ( $flipped ) {
+ $names = [];
+ foreach ( $ids as $id ) {
+ $names[] = $flipped[$id] ?? null;
+ }
+ return array_filter( $names, 'is_string' );
+ } ) );
+
+ return new MultiUsernameFilter( $idLookup );
+ }
+}
'notjson.txt',
'notjson.txt is not valid JSON'
],
+ [
+ 'duplicate_keys.json',
+ 'Duplicate key: name'
+ ],
[
'no_manifest_version.json',
'no_manifest_version.json does not have manifest_version set.'
'FakeExtension' => [
'MediaWiki' => $constraint,
],
- ] )
- );
+ ] ) );
}
public static function provideCheck() {
*/
public function testType( $given, $expected ) {
$checker = new VersionChecker( '1.0.0' );
- $checker
- ->setLoadedExtensionsAndSkins( [
+ $checker->setLoadedExtensionsAndSkins( [
'FakeDependency' => [
'version' => '1.0.0',
],
] );
$this->assertEquals( $expected, $checker->checkArray( [
'FakeExtension' => $given,
- ] )
- );
+ ] ) );
}
public static function provideType() {
[
[
'extensions' => [
- 'FakeDependency' => '1.0.0'
- ]
+ 'FakeDependency' => '1.0.0',
+ ],
],
- []
+ [],
],
[
[
- 'MediaWiki' => '1.0.0'
+ 'MediaWiki' => '1.0.0',
],
- []
+ [],
],
[
[
'extensions' => [
- 'NoVersionGiven' => '*'
- ]
+ 'NoVersionGiven' => '*',
+ ],
],
[],
],
[
'extensions' => [
'NoVersionGiven' => '1.0',
- ]
+ ],
+ ],
+ [
+ [
+ 'incompatible' => 'FakeExtension',
+ 'type' => 'incompatible-extensions',
+ 'msg' => 'NoVersionGiven does not expose its version, but FakeExtension requires: 1.0.',
+ ],
],
- [ [
- 'incompatible' => 'FakeExtension',
- 'type' => 'incompatible-extensions',
- 'msg' => 'NoVersionGiven does not expose its version, but FakeExtension requires: 1.0.'
- ] ],
],
[
[
'extensions' => [
'Missing' => '*',
- ]
+ ],
+ ],
+ [
+ [
+ 'missing' => 'Missing',
+ 'type' => 'missing-extensions',
+ 'msg' => 'FakeExtension requires Missing to be installed.',
+ ],
],
- [ [
- 'missing' => 'Missing',
- 'type' => 'missing-extensions',
- 'msg' => 'FakeExtension requires Missing to be installed.',
- ] ],
],
[
[
'extensions' => [
'FakeDependency' => '2.0.0',
- ]
- ],
- [ [
- 'incompatible' => 'FakeExtension',
- 'type' => 'incompatible-extensions',
- // phpcs:ignore Generic.Files.LineLength.TooLong
- 'msg' => 'FakeExtension is not compatible with the current installed version of FakeDependency (1.0.0), it requires: 2.0.0.'
- ] ],
- ]
+ ],
+ ],
+ [
+ [
+ 'incompatible' => 'FakeExtension',
+ 'type' => 'incompatible-extensions',
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ 'msg' => 'FakeExtension is not compatible with the current installed version of FakeDependency (1.0.0), it requires: 2.0.0.',
+ ],
+ ],
+ ],
+ [
+ [
+ 'skins' => [
+ 'FakeSkin' => '*',
+ ],
+ ],
+ [
+ [
+ 'missing' => 'FakeSkin',
+ 'type' => 'missing-skins',
+ 'msg' => 'FakeExtension requires FakeSkin to be installed.',
+ ],
+ ],
+ ],
];
}
*/
public function testInvalidConstraint() {
$checker = new VersionChecker( '1.0.0' );
- $checker
- ->setLoadedExtensionsAndSkins( [
+ $checker->setLoadedExtensionsAndSkins( [
'FakeDependency' => [
'version' => 'not really valid',
],
] );
- $this->assertEquals(
- [ [
+ $this->assertEquals( [
+ [
'type' => 'invalid-version',
- 'msg' => "FakeDependency does not have a valid version string."
- ] ],
- $checker->checkArray( [
- 'FakeExtension' => [
- 'extensions' => [
- 'FakeDependency' => '1.24.3',
- ],
+ 'msg' => "FakeDependency does not have a valid version string.",
+ ],
+ ], $checker->checkArray( [
+ 'FakeExtension' => [
+ 'extensions' => [
+ 'FakeDependency' => '1.24.3',
],
- ] )
- );
+ ],
+ ] ) );
$checker = new VersionChecker( '1.0.0' );
- $checker
- ->setLoadedExtensionsAndSkins( [
+ $checker->setLoadedExtensionsAndSkins( [
'FakeDependency' => [
'version' => '1.24.3',
],
$checker->checkArray( [
'FakeExtension' => [
'FakeDependency' => 'not really valid',
- ]
+ ],
] );
}
+
+ /**
+ * T197478
+ */
+ public function testInvalidDependency() {
+ $checker = new VersionChecker( '1.0.0' );
+ $this->setExpectedException( UnexpectedValueException::class,
+ 'Dependency type skin unknown in FakeExtension' );
+ $this->assertEquals( [
+ [
+ 'type' => 'invalid-version',
+ 'msg' => 'FakeDependency does not have a valid version string.',
+ ],
+ ], $checker->checkArray( [
+ 'FakeExtension' => [
+ 'skin' => [
+ 'FakeSkin' => '*',
+ ],
+ ],
+ ] ) );
+ }
}
EDIT_NEW | EDIT_SUPPRESS_RC
);
}
+
+ public function provideDataForParseNamespacePrefix() {
+ return [
+ 'noop' => [
+ [
+ 'query' => 'foo',
+ ],
+ false
+ ],
+ 'empty' => [
+ [
+ 'query' => '',
+ ],
+ false,
+ ],
+ 'namespace prefix' => [
+ [
+ 'query' => 'help:test',
+ ],
+ [ 'test', [ NS_HELP ] ],
+ ],
+ 'accented namespace prefix with hook' => [
+ [
+ 'query' => 'hélp:test',
+ 'withHook' => true,
+ ],
+ [ 'test', [ NS_HELP ] ],
+ ],
+ 'accented namespace prefix without hook' => [
+ [
+ 'query' => 'hélp:test',
+ 'withHook' => false,
+ ],
+ false,
+ ],
+ 'all with all keyword allowed' => [
+ [
+ 'query' => 'all:test',
+ 'withAll' => true,
+ ],
+ [ 'test', null ],
+ ],
+ 'all with all keyword disallowed' => [
+ [
+ 'query' => 'all:test',
+ 'withAll' => false,
+ ],
+ false
+ ],
+ 'ns only' => [
+ [
+ 'query' => 'help:',
+ ],
+ [ '', [ NS_HELP ] ]
+ ],
+ 'all only' => [
+ [
+ 'query' => 'all:',
+ 'withAll' => true,
+ ],
+ [ '', null ]
+ ],
+ 'all wins over namespace when first' => [
+ [
+ 'query' => 'all:help:test',
+ 'withAll' => true,
+ ],
+ [ 'help:test', null ]
+ ],
+ 'ns wins over all when first' => [
+ [
+ 'query' => 'help:all:test',
+ 'withAll' => true,
+ ],
+ [ 'all:test', [ NS_HELP ] ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideDataForParseNamespacePrefix
+ * @param array $params
+ * @param array|false $expected
+ * @throws FatalError
+ * @throws MWException
+ */
+ public function testParseNamespacePrefix( array $params, $expected ) {
+ $this->setTemporaryHook( 'PrefixSearchExtractNamespace', function ( &$namespaces, &$query ) {
+ if ( strpos( $query, 'hélp:' ) === 0 ) {
+ $namespaces = [ NS_HELP ];
+ $query = substr( $query, strlen( 'hélp:' ) );
+ }
+ return false;
+ } );
+ $testSet = [];
+ if ( isset( $params['withAll'] ) && isset( $params['withHook'] ) ) {
+ $testSet[] = $params;
+ } elseif ( isset( $params['withAll'] ) ) {
+ $testSet[] = $params + [ 'withHook' => true ];
+ $testSet[] = $params + [ 'withHook' => false ];
+ } elseif ( isset( $params['withHook'] ) ) {
+ $testSet[] = $params + [ 'withAll' => true ];
+ $testSet[] = $params + [ 'withAll' => false ];
+ } else {
+ $testSet[] = $params + [ 'withAll' => true, 'withHook' => true ];
+ $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
+ $testSet[] = $params + [ 'withAll' => false, 'withHook' => false ];
+ $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
+ }
+
+ foreach ( $testSet as $test ) {
+ $actual = SearchEngine::parseNamespacePrefixes( $test['query'],
+ $test['withAll'], $test['withHook'] );
+ $this->assertEquals( $expected, $actual, 'with params: ' . print_r( $test, true ) );
+ }
+ }
}
]
) );
+ # Needs to return something
+ $user->method( 'getOptions' )
+ ->willReturn( [] );
+
# Forge a request to call the special page
$context = new RequestContext();
$context->setRequest( new FauxRequest() );
*/
public static function addMockResults( $query, array $result ) {
// Leading : ensures we don't treat another : as a namespace separator
- $normalized = Title::newFromText( ":$query" )->getText();
+ $normalized = mb_strtolower( Title::newFromText( ":$query" )->getText() );
self::$results[$normalized] = $result;
}
public function completionSearchBackend( $search ) {
+ $search = mb_strtolower( $search );
if ( !isset( self::$results[$search] ) ) {
return SearchSuggestionSet::emptySuggestionSet();
}