Merge "AuthManager: Don't invalidate BotPasswords if a password reset email is sent"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 18 Jul 2018 10:33:14 +0000 (10:33 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 18 Jul 2018 10:33:14 +0000 (10:33 +0000)
41 files changed:
RELEASE-NOTES-1.32
composer.json
includes/PageProps.php
includes/Preferences.php
includes/PrefixSearch.php
includes/api/ApiQuerySearch.php
includes/filerepo/FileBackendDBRepoWrapper.php
includes/filerepo/RepoGroup.php
includes/jobqueue/JobQueueGroup.php
includes/preferences/DefaultPreferencesFactory.php
includes/preferences/Filter.php [new file with mode: 0644]
includes/preferences/IntvalFilter.php [new file with mode: 0644]
includes/preferences/MultiUsernameFilter.php [new file with mode: 0644]
includes/preferences/TimezoneFilter.php [new file with mode: 0644]
includes/registration/ExtensionJsonValidator.php
includes/registration/VersionChecker.php
includes/search/SearchDatabase.php
includes/search/SearchEngine.php
includes/search/SearchMssql.php
includes/search/SearchMySQL.php
includes/search/SearchOracle.php
includes/search/SearchPostgres.php
includes/search/SearchSqlite.php
includes/specials/SpecialEmailuser.php
includes/specials/SpecialSearch.php
includes/user/User.php
includes/widget/search/SearchFormWidget.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/interwiki.list
maintenance/interwiki.sql
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less
tests/phpunit/data/registration/duplicate_keys.json [new file with mode: 0644]
tests/phpunit/includes/api/ApiOptionsTest.php
tests/phpunit/includes/preferences/DefaultPreferencesFactoryTest.php
tests/phpunit/includes/preferences/FiltersTest.php [new file with mode: 0644]
tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php
tests/phpunit/includes/registration/VersionCheckerTest.php
tests/phpunit/includes/search/SearchEngineTest.php
tests/phpunit/includes/specials/SpecialPreferencesTest.php
tests/phpunit/mocks/search/MockCompletionSearchEngine.php

index a3bc7d3..c2dbd9d 100644 (file)
@@ -92,6 +92,9 @@ production.
 * 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.
@@ -264,6 +267,14 @@ because of Phabricator reports.
   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
index f8de4c3..3cd5ea3 100644 (file)
@@ -63,6 +63,7 @@
                "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",
index ff8deee..df2451c 100644 (file)
@@ -81,7 +81,7 @@ class PageProps {
         * Create a PageProps object
         */
        private function __construct() {
-               $this->cache = new ProcessCacheLRU( self::CACHE_SIZE );
+               $this->cache = new MapCacheLRU( self::CACHE_SIZE );
        }
 
        /**
@@ -89,8 +89,8 @@ class PageProps {
         * @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 );
                }
        }
 
@@ -267,11 +267,11 @@ class PageProps {
         * @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];
                        }
@@ -286,8 +286,8 @@ class PageProps {
         * @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;
        }
@@ -300,7 +300,7 @@ class PageProps {
         * @param mixed $propertyValue value of property
         */
        private function cacheProperty( $pageID, $propertyName, $propertyValue ) {
-               $this->cache->set( $pageID, $propertyName, $propertyValue );
+               $this->cache->setField( $pageID, $propertyName, $propertyValue );
        }
 
        /**
@@ -311,6 +311,6 @@ class PageProps {
         */
        private function cacheProperties( $pageID, $pageProperties ) {
                $this->cache->clear( $pageID );
-               $this->cache->set( 0, $pageID, $pageProperties );
+               $this->cache->setField( 0, $pageID, $pageProperties );
        }
 }
index c458af0..a8a312c 100644 (file)
@@ -300,30 +300,6 @@ class Preferences {
                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
index 5127158..63a4d9c 100644 (file)
@@ -58,54 +58,14 @@ abstract class PrefixSearch {
                        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
index 3d87a5f..f5461e0 100644 (file)
@@ -66,9 +66,19 @@ class ApiQuerySearch extends ApiQueryGeneratorBase {
                $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 );
index dbb5421..b445487 100644 (file)
@@ -45,7 +45,7 @@ class FileBackendDBRepoWrapper extends FileBackend {
        protected $repoName;
        /** @var Closure */
        protected $dbHandleFunc;
-       /** @var ProcessCacheLRU */
+       /** @var MapCacheLRU */
        protected $resolvedPathCache;
        /** @var DBConnRef[] */
        protected $dbs;
@@ -59,7 +59,7 @@ class FileBackendDBRepoWrapper extends FileBackend {
                $this->backend = $config['backend'];
                $this->repoName = $config['repoName'];
                $this->dbHandleFunc = $config['dbHandleFactory'];
-               $this->resolvedPathCache = new ProcessCacheLRU( 100 );
+               $this->resolvedPathCache = new MapCacheLRU( 100 );
        }
 
        /**
@@ -102,8 +102,8 @@ class FileBackendDBRepoWrapper extends FileBackend {
                // @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;
                        }
 
@@ -127,12 +127,12 @@ class FileBackendDBRepoWrapper extends FileBackend {
                                        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;
                        }
index fa4567e..90c8707 100644 (file)
@@ -98,7 +98,7 @@ class RepoGroup {
        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 );
        }
 
        /**
@@ -141,8 +141,8 @@ class RepoGroup {
                        && 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 {
@@ -166,7 +166,7 @@ class RepoGroup {
                $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;
index addc7fc..37c8890 100644 (file)
@@ -62,7 +62,7 @@ class JobQueueGroup {
        protected function __construct( $wiki, $readOnlyReason ) {
                $this->wiki = $wiki;
                $this->readOnlyReason = $readOnlyReason;
-               $this->cache = new ProcessCacheLRU( 10 );
+               $this->cache = new MapCacheLRU( 10 );
        }
 
        /**
@@ -154,8 +154,8 @@ class JobQueueGroup {
                        $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' );
                        }
@@ -244,10 +244,10 @@ class JobQueueGroup {
                        }
                } 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();
                        }
index 8c113f4..830da06 100644 (file)
@@ -20,7 +20,6 @@
 
 namespace MediaWiki\Preferences;
 
-use CentralIdLookup;
 use Config;
 use DateTime;
 use DateTimeZone;
@@ -53,6 +52,7 @@ use SpecialPage;
 use SpecialPreferences;
 use Status;
 use Title;
+use UnexpectedValueException;
 use User;
 use UserGroupMembership;
 use Xml;
@@ -94,22 +94,6 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                $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
         */
@@ -178,9 +162,11 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                $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';
                        }
@@ -209,11 +195,11 @@ class DefaultPreferencesFactory implements PreferencesFactory {
         *
         * @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' ) ||
@@ -223,7 +209,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                        $val = [];
 
                        foreach ( $options as $value ) {
-                               if ( $user->getOption( "$prefix$value" ) ) {
+                               if ( $userOptions["$prefix$value"] ?? false ) {
                                        $val[] = $value;
                                }
                        }
@@ -239,7 +225,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
 
                        foreach ( $columns as $column ) {
                                foreach ( $rows as $row ) {
-                                       if ( $user->getOption( "$prefix$column-$row" ) ) {
+                                       if ( $userOptions["$prefix$column-$row"] ?? false ) {
                                                $val[] = "$column-$row";
                                        }
                                }
@@ -653,16 +639,12 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                                ];
 
                                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,
                                        ];
                                }
                        }
@@ -850,6 +832,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                        'size' => 20,
                        'section' => 'rendering/timeoffset',
                        'id' => 'wpTimeCorrection',
+                       'filter' => TimezoneFilter::class,
                ];
        }
 
@@ -1010,6 +993,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                        'label-message' => 'recentchangescount',
                        'help-message' => 'prefs-help-recentchangescount',
                        'section' => 'rc/displayrc',
+                       'filter' => IntvalFilter::class,
                ];
                $defaultPreferences['usenewrc'] = [
                        'type' => 'toggle',
@@ -1153,6 +1137,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                        'label-message' => 'prefs-watchlist-edits',
                        'help' => $context->msg( 'prefs-watchlist-edits-max' )->escaped(),
                        'section' => 'watchlist/displaywatchlist',
+                       'filter' => IntvalFilter::class,
                ];
                $defaultPreferences['extendwatchlist'] = [
                        'type' => 'toggle',
@@ -1533,9 +1518,11 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                # 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;
        }
@@ -1584,64 +1571,16 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                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;
@@ -1651,12 +1590,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                }
 
                // 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)
@@ -1713,16 +1647,32 @@ class DefaultPreferencesFactory implements PreferencesFactory {
        }
 
        /**
-        * 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] );
+               }
        }
 
        /**
@@ -1730,10 +1680,11 @@ class DefaultPreferencesFactory implements PreferencesFactory {
         *
         * @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();
@@ -1763,19 +1714,6 @@ class DefaultPreferencesFactory implements PreferencesFactory {
                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
diff --git a/includes/preferences/Filter.php b/includes/preferences/Filter.php
new file mode 100644 (file)
index 0000000..670dd5b
--- /dev/null
@@ -0,0 +1,39 @@
+<?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 );
+}
diff --git a/includes/preferences/IntvalFilter.php b/includes/preferences/IntvalFilter.php
new file mode 100644 (file)
index 0000000..0dd3fc5
--- /dev/null
@@ -0,0 +1,38 @@
+<?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 );
+       }
+}
diff --git a/includes/preferences/MultiUsernameFilter.php b/includes/preferences/MultiUsernameFilter.php
new file mode 100644 (file)
index 0000000..2d8ae3c
--- /dev/null
@@ -0,0 +1,86 @@
+<?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;
+       }
+}
diff --git a/includes/preferences/TimezoneFilter.php b/includes/preferences/TimezoneFilter.php
new file mode 100644 (file)
index 0000000..53f12de
--- /dev/null
@@ -0,0 +1,84 @@
+<?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;
+               }
+       }
+}
index 564ea6b..7d59a02 100644 (file)
@@ -21,6 +21,8 @@
 
 use Composer\Spdx\SpdxLicenses;
 use JsonSchema\Validator;
+use Seld\JsonLint\JsonParser;
+use Seld\JsonLint\ParsingException;
 
 /**
  * @since 1.29
@@ -54,6 +56,10 @@ class ExtensionJsonValidator {
                                '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;
@@ -65,8 +71,14 @@ class ExtensionJsonValidator {
         * @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" );
                }
 
index 9c673bc..59853b4 100644 (file)
@@ -117,7 +117,7 @@ class VersionChecker {
                                                }
                                                break;
                                        case 'extensions':
-                                       case 'skin':
+                                       case 'skins':
                                                foreach ( $values as $dependency => $constraint ) {
                                                        $extError = $this->handleExtensionDependency(
                                                                $dependency, $constraint, $extension, $dependencyType
@@ -169,7 +169,7 @@ class VersionChecker {
         * @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,
index f2929a3..93f8d23 100644 (file)
@@ -28,7 +28,7 @@ use Wikimedia\Rdbms\IDatabase;
  * @ingroup Search
  * @since 1.23
  */
-class SearchDatabase extends SearchEngine {
+abstract class SearchDatabase extends SearchEngine {
        /**
         * @var IDatabase Slave database for reading from for results
         */
@@ -45,6 +45,38 @@ class SearchDatabase extends SearchEngine {
                }
        }
 
+       /**
+        * @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
         *
@@ -58,4 +90,19 @@ class SearchDatabase extends SearchEngine {
                $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];
+       }
 }
index 63c4610..30c2271 100644 (file)
@@ -244,6 +244,8 @@ abstract class SearchEngine {
         * 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;
@@ -385,16 +387,12 @@ abstract class SearchEngine {
         * 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;
        }
 
        /**
@@ -402,11 +400,21 @@ abstract class SearchEngine {
         * 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;
@@ -414,40 +422,48 @@ abstract class SearchEngine {
                        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 ];
        }
 
@@ -530,34 +546,11 @@ abstract class SearchEngine {
         * @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;
        }
 
index 30ac92d..289f925 100644 (file)
@@ -34,7 +34,7 @@ class SearchMssql extends SearchDatabase {
         * @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 );
        }
@@ -45,7 +45,7 @@ class SearchMssql extends SearchDatabase {
         * @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 );
        }
index 9a03ebe..6253b55 100644 (file)
@@ -167,7 +167,7 @@ class SearchMySQL extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchText( $term ) {
+       protected function doSearchTextInDB( $term ) {
                return $this->searchInternal( $term, true );
        }
 
@@ -177,7 +177,7 @@ class SearchMySQL extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchTitle( $term ) {
+       protected function doSearchTitleInDB( $term ) {
                return $this->searchInternal( $term, false );
        }
 
index 7fe5b53..6d7e988 100644 (file)
@@ -64,7 +64,7 @@ class SearchOracle extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchText( $term ) {
+       protected function doSearchTextInDB( $term ) {
                if ( $term == '' ) {
                        return new SqlSearchResultSet( false, '' );
                }
@@ -79,7 +79,7 @@ class SearchOracle extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchTitle( $term ) {
+       protected function doSearchTitleInDB( $term ) {
                if ( $term == '' ) {
                        return new SqlSearchResultSet( false, '' );
                }
index 729e528..6d5f117 100644 (file)
@@ -37,7 +37,7 @@ class SearchPostgres extends SearchDatabase {
         * @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 );
@@ -45,7 +45,7 @@ class SearchPostgres extends SearchDatabase {
                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 );
index 1dc37d2..0ed477a 100644 (file)
@@ -156,7 +156,7 @@ class SearchSqlite extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchText( $term ) {
+       protected function doSearchTextInDB( $term ) {
                return $this->searchInternal( $term, true );
        }
 
@@ -166,7 +166,7 @@ class SearchSqlite extends SearchDatabase {
         * @param string $term Raw search term
         * @return SqlSearchResultSet
         */
-       protected function doSearchTitle( $term ) {
+       protected function doSearchTitleInDB( $term ) {
                return $this->searchInternal( $term, false );
        }
 
index d7ce414..9248a40 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup SpecialPage
  */
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Preferences\MultiUsernameFilter;
 
 /**
  * A special page that allows users to send e-mails to other users
@@ -247,8 +248,9 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                }
 
                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 ) ) {
index 13259c9..2cff90e 100644 (file)
@@ -194,6 +194,9 @@ class SpecialSearch extends SpecialPage {
                $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();
 
@@ -300,7 +303,6 @@ class SpecialSearch extends SpecialPage {
                $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 ] ) ) {
@@ -312,9 +314,20 @@ class SpecialSearch extends SpecialPage {
                $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 );
 
@@ -531,6 +544,28 @@ class SpecialSearch extends SpecialPage {
                        );
                }
 
+               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( [
@@ -712,6 +747,18 @@ class SpecialSearch extends SpecialPage {
                $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';
        }
index ea8cd57..2ba01ff 100644 (file)
@@ -5507,12 +5507,6 @@ class User implements IDBAccessObject, UserIdentity {
                                }
                        }
 
-                       // 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;
@@ -5540,26 +5534,6 @@ class User implements IDBAccessObject, UserIdentity {
                // 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 ] ) ) {
index e40ae29..2302177 100644 (file)
@@ -100,6 +100,10 @@ class SearchFormWidget {
 
                $html .= $layout;
 
+               if ( $this->specialSearch->getPrefix() !== '' ) {
+                       $html .= Html::hidden( 'prefix', $this->specialSearch->getPrefix() );
+               }
+
                if ( $totalResults > 0 && $offset < $totalResults ) {
                        $html .= Xml::tags(
                                'div',
index 0114a69..bc35c9e 100644 (file)
        "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",
index 6004aa1..573c029 100644 (file)
        "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]].",
index 5f87e16..039666b 100644 (file)
@@ -56,7 +56,7 @@ wikidata|https://www.wikidata.org/wiki/$1|0|https://www.wikidata.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
index 9e6072b..68ebedf 100644 (file)
@@ -58,7 +58,7 @@ REPLACE INTO /*$wgDBprefix*/interwiki (iw_prefix,iw_url,iw_local,iw_api) VALUES
 ('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'),
index 390d873..9101fba 100644 (file)
                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 );
        }
 }
diff --git a/tests/phpunit/data/registration/duplicate_keys.json b/tests/phpunit/data/registration/duplicate_keys.json
new file mode 100644 (file)
index 0000000..40f2f7e
--- /dev/null
@@ -0,0 +1,4 @@
+{
+       "name": "FooBar",
+       "name": "Test"
+}
index fbc1bed..29c7dae 100644 (file)
@@ -40,6 +40,10 @@ class ApiOptionsTest extends MediaWikiLangTestCase {
                $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' ) );
index 9a8608f..296691d 100644 (file)
@@ -164,8 +164,9 @@ class DefaultPreferencesFactoryTest extends MediaWikiTestCase {
                        }
                );
 
+               /** @var DefaultPreferencesFactory $factory */
                $factory = TestingAccessWrapper::newFromObject( $this->getPreferencesFactory() );
-               $factory->saveFormData( $newOptions, $form );
+               $factory->saveFormData( $newOptions, $form, [] );
        }
 
        /**
diff --git a/tests/phpunit/includes/preferences/FiltersTest.php b/tests/phpunit/includes/preferences/FiltersTest.php
new file mode 100644 (file)
index 0000000..42cbc2c
--- /dev/null
@@ -0,0 +1,141 @@
+<?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 );
+       }
+}
index 355f4ef..46c697f 100644 (file)
@@ -52,6 +52,10 @@ class ExtensionJsonValidatorTest extends MediaWikiTestCase {
                                '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.'
index 35d4ea0..b668a9a 100644 (file)
@@ -17,8 +17,7 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
                        'FakeExtension' => [
                                'MediaWiki' => $constraint,
                        ],
-               ] )
-               );
+               ] ) );
        }
 
        public static function provideCheck() {
@@ -50,8 +49,7 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
         */
        public function testType( $given, $expected ) {
                $checker = new VersionChecker( '1.0.0' );
-               $checker
-                       ->setLoadedExtensionsAndSkins( [
+               $checker->setLoadedExtensionsAndSkins( [
                                'FakeDependency' => [
                                        'version' => '1.0.0',
                                ],
@@ -59,8 +57,7 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
                        ] );
                $this->assertEquals( $expected, $checker->checkArray( [
                        'FakeExtension' => $given,
-               ] )
-               );
+               ] ) );
        }
 
        public static function provideType() {
@@ -69,22 +66,22 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
                        [
                                [
                                        'extensions' => [
-                                               'FakeDependency' => '1.0.0'
-                                       ]
+                                               'FakeDependency' => '1.0.0',
+                                       ],
                                ],
-                               []
+                               [],
                        ],
                        [
                                [
-                                       'MediaWiki' => '1.0.0'
+                                       'MediaWiki' => '1.0.0',
                                ],
-                               []
+                               [],
                        ],
                        [
                                [
                                        'extensions' => [
-                                               'NoVersionGiven' => '*'
-                                       ]
+                                               'NoVersionGiven' => '*',
+                                       ],
                                ],
                                [],
                        ],
@@ -92,39 +89,59 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
                                [
                                        '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.',
+                                       ],
+                               ],
+                       ],
                ];
        }
 
@@ -134,29 +151,26 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
         */
        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',
                                ],
@@ -166,7 +180,28 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase {
                $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' => '*',
+                               ],
+                       ],
+               ] ) );
+       }
 }
index 5884d19..c1f5cf8 100644 (file)
@@ -340,4 +340,120 @@ class SearchEngineTest extends MediaWikiLangTestCase {
                        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 ) );
+               }
+       }
 }
index bdfbb62..cd6cd3b 100644 (file)
@@ -43,6 +43,10 @@ class SpecialPreferencesTest extends MediaWikiTestCase {
                        ]
                        ) );
 
+               # Needs to return something
+               $user->method( 'getOptions' )
+                       ->willReturn( [] );
+
                # Forge a request to call the special page
                $context = new RequestContext();
                $context->setRequest( new FauxRequest() );
index ac8a5dc..13d16df 100644 (file)
@@ -27,11 +27,12 @@ class MockCompletionSearchEngine extends SearchEngine {
         */
        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();
                }