Merge "Avoid unstubbing $wgUser before the end of Setup.php in User::getBlockedStatus()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 4 Feb 2016 16:42:07 +0000 (16:42 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 4 Feb 2016 16:42:07 +0000 (16:42 +0000)
46 files changed:
autoload.php
images/.htaccess
includes/GlobalFunctions.php
includes/Message.php
includes/PrefixSearch.php
includes/Setup.php
includes/api/ApiOpenSearch.php
includes/api/ApiQueryPrefixSearch.php
includes/context/RequestContext.php
includes/debug/logger/monolog/AvroFormatter.php
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/CachedBagOStuff.php [new file with mode: 0644]
includes/media/Bitmap.php
includes/page/ImageHistoryList.php [new file with mode: 0644]
includes/page/ImageHistoryPseudoPager.php [new file with mode: 0644]
includes/page/ImagePage.php
includes/parser/Preprocessor_DOM.php
includes/parser/Preprocessor_Hash.php
includes/search/SearchEngine.php
includes/search/SearchSuggestion.php [new file with mode: 0644]
includes/search/SearchSuggestionSet.php [new file with mode: 0644]
includes/session/PHPSessionHandler.php
includes/session/SessionBackend.php
includes/session/SessionManager.php
includes/specialpage/SpecialPage.php
includes/specials/SpecialAllPages.php
includes/specials/SpecialChangeContentModel.php
includes/specials/SpecialFileDuplicateSearch.php
includes/specials/SpecialMovepage.php
includes/specials/SpecialPageLanguage.php
includes/specials/SpecialPrefixindex.php
includes/specials/SpecialRecentchangeslinked.php
includes/specials/SpecialUndelete.php
includes/specials/SpecialWhatlinkshere.php
tests/parser/parserTests.txt
tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php
tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php [new file with mode: 0644]
tests/phpunit/includes/parser/PreprocessorTest.php
tests/phpunit/includes/search/SearchEnginePrefixTest.php [new file with mode: 0644]
tests/phpunit/includes/search/SearchSuggestionSetTest.php [new file with mode: 0644]
tests/phpunit/includes/session/CookieSessionProviderTest.php
tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
tests/phpunit/includes/session/PHPSessionHandlerTest.php
tests/phpunit/includes/session/SessionBackendTest.php
tests/phpunit/includes/session/SessionManagerTest.php
tests/phpunit/includes/session/TestBagOStuff.php

index 9073005..4d48de0 100644 (file)
@@ -190,6 +190,7 @@ $wgAutoloadLocalClasses = array(
        'CacheHelper' => __DIR__ . '/includes/cache/CacheHelper.php',
        'CacheTime' => __DIR__ . '/includes/parser/CacheTime.php',
        'CachedAction' => __DIR__ . '/includes/actions/CachedAction.php',
+       'CachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/CachedBagOStuff.php',
        'CachingSiteStore' => __DIR__ . '/includes/site/CachingSiteStore.php',
        'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php',
        'Category' => __DIR__ . '/includes/Category.php',
@@ -568,8 +569,8 @@ $wgAutoloadLocalClasses = array(
        'ImageGallery' => __DIR__ . '/includes/gallery/TraditionalImageGallery.php',
        'ImageGalleryBase' => __DIR__ . '/includes/gallery/ImageGalleryBase.php',
        'ImageHandler' => __DIR__ . '/includes/media/ImageHandler.php',
-       'ImageHistoryList' => __DIR__ . '/includes/page/ImagePage.php',
-       'ImageHistoryPseudoPager' => __DIR__ . '/includes/page/ImagePage.php',
+       'ImageHistoryList' => __DIR__ . '/includes/page/ImageHistoryList.php',
+       'ImageHistoryPseudoPager' => __DIR__ . '/includes/page/ImageHistoryPseudoPager.php',
        'ImageListPager' => __DIR__ . '/includes/specials/SpecialListfiles.php',
        'ImagePage' => __DIR__ . '/includes/page/ImagePage.php',
        'ImageQueryPage' => __DIR__ . '/includes/specialpage/ImageQueryPage.php',
@@ -1135,6 +1136,8 @@ $wgAutoloadLocalClasses = array(
        'SearchResult' => __DIR__ . '/includes/search/SearchResult.php',
        'SearchResultSet' => __DIR__ . '/includes/search/SearchResultSet.php',
        'SearchSqlite' => __DIR__ . '/includes/search/SearchSqlite.php',
+       'SearchSuggestion' => __DIR__ . '/includes/search/SearchSuggestion.php',
+       'SearchSuggestionSet' => __DIR__ . '/includes/search/SearchSuggestionSet.php',
        'SearchUpdate' => __DIR__ . '/includes/deferred/SearchUpdate.php',
        'SectionProfileCallback' => __DIR__ . '/includes/profiler/SectionProfiler.php',
        'SectionProfiler' => __DIR__ . '/includes/profiler/SectionProfiler.php',
index 3f3d41e..4e253b6 100644 (file)
@@ -1,4 +1,4 @@
-# Protect against bug 28235
+# Protect against bug T30235
 <IfModule rewrite_module>
        RewriteEngine On
        RewriteOptions inherit
index 4066945..66201b5 100644 (file)
@@ -3082,7 +3082,6 @@ function wfSetupSession( $sessionId = false ) {
        if ( session_id() !== $session->getId() ) {
                session_id( $session->getId() );
        }
-       MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
        MediaWiki\quietCall( 'session_start' );
 }
 
index 54efd26..c71a953 100644 (file)
@@ -271,7 +271,7 @@ class Message implements MessageSpecifier, Serializable {
        public function serialize() {
                return serialize( array(
                        'interface' => $this->interface,
-                       'language' => $this->language->getCode(),
+                       'language' => $this->language instanceof StubUserLang ? false : $this->language->getCode(),
                        'key' => $this->key,
                        'keysToTry' => $this->keysToTry,
                        'parameters' => $this->parameters,
@@ -287,6 +287,8 @@ class Message implements MessageSpecifier, Serializable {
         * @param string $serialized
         */
        public function unserialize( $serialized ) {
+               global $wgLang;
+
                $data = unserialize( $serialized );
                $this->interface = $data['interface'];
                $this->key = $data['key'];
@@ -294,7 +296,7 @@ class Message implements MessageSpecifier, Serializable {
                $this->parameters = $data['parameters'];
                $this->format = $data['format'];
                $this->useDatabase = $data['useDatabase'];
-               $this->language = Language::factory( $data['language'] );
+               $this->language = $data['language'] ? Language::factory( $data['language'] ) : $wgLang;
                $this->title = $data['title'];
        }
 
index c6f187d..5f36cf5 100644 (file)
@@ -23,6 +23,7 @@
 /**
  * Handles searching prefixes of titles and finding any page
  * names that match. Used largely by the OpenSearch implementation.
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  *
  * @ingroup Search
  */
@@ -259,14 +260,17 @@ abstract class PrefixSearch {
         * @param int $offset Number of items to skip
         * @return array Array of Title objects
         */
-       protected function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
+       public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
                $ns = array_shift( $namespaces ); // support only one namespace
-               if ( in_array( NS_MAIN, $namespaces ) ) {
+               if ( is_null( $ns ) || in_array( NS_MAIN, $namespaces ) ) {
                        $ns = NS_MAIN; // if searching on many always default to main
                }
 
-               $t = Title::newFromText( $search, $ns );
+               if ( $ns == NS_SPECIAL ) {
+                       return $this->specialSearch( $search, $limit, $offset );
+               }
 
+               $t = Title::newFromText( $search, $ns );
                $prefix = $t ? $t->getDBkey() : '';
                $dbr = wfGetDB( DB_SLAVE );
                $res = $dbr->select( 'page',
@@ -318,6 +322,7 @@ abstract class PrefixSearch {
 
 /**
  * Performs prefix search, returning Title objects
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  * @ingroup Search
  */
 class TitlePrefixSearch extends PrefixSearch {
@@ -337,6 +342,7 @@ class TitlePrefixSearch extends PrefixSearch {
 
 /**
  * Performs prefix search, returning strings
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  * @ingroup Search
  */
 class StringPrefixSearch extends PrefixSearch {
index 6c85638..ba3d628 100644 (file)
@@ -738,7 +738,6 @@ if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
        ) {
                // Start the PHP-session for backwards compatibility
                session_id( $session->getId() );
-               MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
                MediaWiki\quietCall( 'session_start' );
        }
 }
index 5ce43cc..ff5707e 100644 (file)
@@ -123,9 +123,12 @@ class ApiOpenSearch extends ApiBase {
         * @param array &$results Put results here. Keys have to be integers.
         */
        protected function search( $search, $limit, $namespaces, $resolveRedir, &$results ) {
-               // Find matching titles as Title objects
-               $searcher = new TitlePrefixSearch;
-               $titles = $searcher->searchWithVariants( $search, $limit, $namespaces );
+
+               $searchEngine = SearchEngine::create();
+               $searchEngine->setLimitOffset( $limit );
+               $searchEngine->setNamespaces( $namespaces );
+               $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+
                if ( !$titles ) {
                        return;
                }
index 25ff07c..1dac740 100644 (file)
@@ -45,8 +45,11 @@ class ApiQueryPrefixSearch extends ApiQueryGeneratorBase {
                $namespaces = $params['namespace'];
                $offset = $params['offset'];
 
-               $searcher = new TitlePrefixSearch;
-               $titles = $searcher->searchWithVariants( $search, $limit + 1, $namespaces, $offset );
+               $searchEngine = SearchEngine::create();
+               $searchEngine->setLimitOffset( $limit + 1, $offset );
+               $searchEngine->setNamespaces( $namespaces );
+               $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+
                if ( $resultPageSet ) {
                        $resultPageSet->setRedirectMergePolicy( function( array $current, array $new ) {
                                if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
index 73e11b5..8056b4d 100644 (file)
@@ -595,7 +595,6 @@ class RequestContext implements IContextSource, MutableContext {
                        $wgUser = $context->getUser(); // b/c
                        if ( $session && MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
                                session_id( $session->getId() );
-                               MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
                                MediaWiki\quietCall( 'session_start' );
                        }
                        $request = new FauxRequest( array(), false, $session );
index fc24e82..eb1a0d0 100644 (file)
@@ -85,7 +85,7 @@ class AvroFormatter implements FormatterInterface {
                $this->io->truncate();
                $schema = $this->getSchema( $record['channel'] );
                $revId = $this->getSchemaRevisionId( $record['channel'] );
-               if ( $schema === null ) {
+               if ( $schema === null || $revId === null ) {
                        trigger_error( "The schema for channel '{$record['channel']}' is not available" );
                        return null;
                }
@@ -97,11 +97,7 @@ class AvroFormatter implements FormatterInterface {
                        trigger_error( "Avro failed to serialize record for {$record['channel']} : {$json}" );
                        return null;
                }
-               if ( $revId !== null ) {
-                       return chr( self::MAGIC ) . $this->encode_long( $revId ) . $this->io->string();
-               }
-               // @todo: remove backward compat code and do not send messages without rev id.
-               return $this->io->string();
+               return chr( self::MAGIC ) . $this->encodeLong( $revId ) . $this->io->string();
        }
 
        /**
@@ -132,27 +128,23 @@ class AvroFormatter implements FormatterInterface {
                if ( !isset( $this->schemas[$channel] ) ) {
                        return null;
                }
-               $schemaDetails = &$this->schemas[$channel];
-               $schema = null;
-               if ( isset( $schemaDetails['revision'] ) && isset( $schemaDetails['schema'] ) ) {
-                       $schema = &$schemaDetails['schema'];
-               } else {
-                       // @todo: Remove backward compat code
-                       $schema = &$schemaDetails;
+               if ( !isset( $this->schemas[$channel]['revision'], $this->schemas[$channel]['schema'] ) ) {
+                       return null;
                }
 
-               if ( !$schema instanceof AvroSchema ) {
+               if ( !$this->schemas[$channel]['schema'] instanceof AvroSchema ) {
+                       $schema = $this->schemas[$channel]['schema'];
                        if ( is_string( $schema ) ) {
-                               $schema = AvroSchema::parse( $schema );
+                               $this->schemas[$channel]['schema'] = AvroSchema::parse( $schema );
                        } else {
-                               $schema = AvroSchema::real_parse(
-                                       $this->schemas[$channel],
+                               $this->schemas[$channel]['schema'] = AvroSchema::real_parse(
+                                       $schema,
                                        null,
                                        new AvroNamedSchemata()
                                );
                        }
                }
-               return $schema;
+               return $this->schemas[$channel]['schema'];
        }
 
        /**
@@ -162,10 +154,7 @@ class AvroFormatter implements FormatterInterface {
         * @return int|null
         */
        public function getSchemaRevisionId( $channel ) {
-               // @todo: remove backward compat code
-               if ( isset( $this->schemas[$channel] )
-                               && is_array( $this->schemas[$channel] )
-                               && isset( $this->schemas[$channel]['revision'] ) ) {
+               if ( isset( $this->schemas[$channel]['revision'] ) ) {
                        return (int) $this->schemas[$channel]['revision'];
                }
                return null;
@@ -177,7 +166,7 @@ class AvroFormatter implements FormatterInterface {
         * @param int $id
         * @return string the binary representation of $id
         */
-       private function encode_long( $id ) {
+       private function encodeLong( $id ) {
                $high   = ( $id & 0xffffffff00000000 ) >> 32;
                $low    = $id & 0x00000000ffffffff;
                return pack( 'NN', $high, $low );
index b9be43d..3736103 100644 (file)
@@ -69,6 +69,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
        /** Bitfield constants for set()/merge() */
        const WRITE_SYNC = 1; // synchronously write to all locations for replicated stores
+       const WRITE_CACHE_ONLY = 2; // Only change state of the in-memory cache
 
        public function __construct( array $params = array() ) {
                if ( isset( $params['logger'] ) ) {
diff --git a/includes/libs/objectcache/CachedBagOStuff.php b/includes/libs/objectcache/CachedBagOStuff.php
new file mode 100644 (file)
index 0000000..fc15618
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * 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
+ * @ingroup Cache
+ */
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * The differences between CachedBagOStuff and MultiWriteBagOStuff are:
+ * * CachedBagOStuff supports only one "backend".
+ * * There's a flag for writes to only go to the in-memory cache.
+ * * The in-memory cache is always updated.
+ * * Locks go to the backend cache (with MultiWriteBagOStuff, it would wind
+ *   up going to the HashBagOStuff used for the in-memory cache).
+ *
+ * @ingroup Cache
+ */
+class CachedBagOStuff extends HashBagOStuff {
+       /** @var BagOStuff */
+       protected $backend;
+
+       /**
+        * @param BagOStuff $backend Permanent backend to use
+        * @param array $params Parameters for HashBagOStuff
+        */
+       function __construct( BagOStuff $backend, $params = array() ) {
+               $this->backend = $backend;
+               parent::__construct( $params );
+       }
+
+       protected function doGet( $key, $flags = 0 ) {
+               $ret = parent::doGet( $key, $flags );
+               if ( $ret === false ) {
+                       $ret = $this->backend->doGet( $key, $flags );
+                       if ( $ret !== false ) {
+                               $this->set( $key, $ret, 0, self::WRITE_CACHE_ONLY );
+                       }
+               }
+               return $ret;
+       }
+
+       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+               parent::set( $key, $value, $exptime, $flags );
+               if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+                       $this->backend->set( $key, $value, $exptime, $flags & ~self::WRITE_CACHE_ONLY );
+               }
+               return true;
+       }
+
+       public function delete( $key, $flags = 0 ) {
+               unset( $this->bag[$key] );
+               if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+                       $this->backend->delete( $key );
+               }
+
+               return true;
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               parent::setLogger( $logger );
+               $this->backend->setLogger( $logger );
+       }
+
+       public function setDebug( $bool ) {
+               parent::setDebug( $bool );
+               $this->backend->setDebug( $bool );
+       }
+
+       public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+               return $this->backend->lock( $key, $timeout, $expiry, $rclass );
+       }
+
+       public function unlock( $key ) {
+               return $this->backend->unlock( $key );
+       }
+
+       public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) {
+               parent::deleteObjectsExpiringBefore( $date, $progressCallback );
+               return $this->backend->deleteObjectsExpiringBefore( $date, $progressCallback );
+       }
+
+       public function getLastError() {
+               return $this->backend->getLastError();
+       }
+
+       public function clearLastError() {
+               $this->backend->clearLastError();
+       }
+
+       public function modifySimpleRelayEvent( array $event ) {
+               return $this->backend->modifySimpleRelayEvent( $event );
+       }
+
+}
index 9ac5e6b..c2b82d8 100644 (file)
@@ -78,6 +78,14 @@ class BitmapHandler extends TransformationalImageHandler {
                return $params;
        }
 
+       function validateParam( $name, $value ) {
+               if ( $name === 'interlace' ) {
+                       return $value === false || $value === true;
+               } else {
+                       return parent::validateParam( $name, $value );
+               }
+       }
+
        /**
         * @param File $image
         * @param array $params
diff --git a/includes/page/ImageHistoryList.php b/includes/page/ImageHistoryList.php
new file mode 100644 (file)
index 0000000..32638a5
--- /dev/null
@@ -0,0 +1,327 @@
+<?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
+ */
+
+/**
+ * Builds the image revision log shown on image pages
+ *
+ * @ingroup Media
+ */
+class ImageHistoryList extends ContextSource {
+
+       /**
+        * @var Title
+        */
+       protected $title;
+
+       /**
+        * @var File
+        */
+       protected $img;
+
+       /**
+        * @var ImagePage
+        */
+       protected $imagePage;
+
+       /**
+        * @var File
+        */
+       protected $current;
+
+       protected $repo, $showThumb;
+       protected $preventClickjacking = false;
+
+       /**
+        * @param ImagePage $imagePage
+        */
+       public function __construct( $imagePage ) {
+               global $wgShowArchiveThumbnails;
+               $this->current = $imagePage->getFile();
+               $this->img = $imagePage->getDisplayedFile();
+               $this->title = $imagePage->getTitle();
+               $this->imagePage = $imagePage;
+               $this->showThumb = $wgShowArchiveThumbnails && $this->img->canRender();
+               $this->setContext( $imagePage->getContext() );
+       }
+
+       /**
+        * @return ImagePage
+        */
+       public function getImagePage() {
+               return $this->imagePage;
+       }
+
+       /**
+        * @return File
+        */
+       public function getFile() {
+               return $this->img;
+       }
+
+       /**
+        * @param string $navLinks
+        * @return string
+        */
+       public function beginImageHistoryList( $navLinks = '' ) {
+               return Xml::element( 'h2', array( 'id' => 'filehistory' ), $this->msg( 'filehist' )->text() )
+               . "\n"
+               . "<div id=\"mw-imagepage-section-filehistory\">\n"
+               . $this->msg( 'filehist-help' )->parseAsBlock()
+               . $navLinks . "\n"
+               . Xml::openElement( 'table', array( 'class' => 'wikitable filehistory' ) ) . "\n"
+               . '<tr><th></th>'
+               . ( $this->current->isLocal()
+               && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<th></th>' : '' )
+               . '<th>' . $this->msg( 'filehist-datetime' )->escaped() . '</th>'
+               . ( $this->showThumb ? '<th>' . $this->msg( 'filehist-thumb' )->escaped() . '</th>' : '' )
+               . '<th>' . $this->msg( 'filehist-dimensions' )->escaped() . '</th>'
+               . '<th>' . $this->msg( 'filehist-user' )->escaped() . '</th>'
+               . '<th>' . $this->msg( 'filehist-comment' )->escaped() . '</th>'
+               . "</tr>\n";
+       }
+
+       /**
+        * @param string $navLinks
+        * @return string
+        */
+       public function endImageHistoryList( $navLinks = '' ) {
+               return "</table>\n$navLinks\n</div>\n";
+       }
+
+       /**
+        * @param bool $iscur
+        * @param File $file
+        * @return string
+        */
+       public function imageHistoryLine( $iscur, $file ) {
+               global $wgContLang;
+
+               $user = $this->getUser();
+               $lang = $this->getLanguage();
+               $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+               $img = $iscur ? $file->getName() : $file->getArchiveName();
+               $userId = $file->getUser( 'id' );
+               $userText = $file->getUser( 'text' );
+               $description = $file->getDescription( File::FOR_THIS_USER, $user );
+
+               $local = $this->current->isLocal();
+               $row = $selected = '';
+
+               // Deletion link
+               if ( $local && ( $user->isAllowedAny( 'delete', 'deletedhistory' ) ) ) {
+                       $row .= '<td>';
+                       # Link to remove from history
+                       if ( $user->isAllowed( 'delete' ) ) {
+                               $q = array( 'action' => 'delete' );
+                               if ( !$iscur ) {
+                                       $q['oldimage'] = $img;
+                               }
+                               $row .= Linker::linkKnown(
+                                       $this->title,
+                                       $this->msg( $iscur ? 'filehist-deleteall' : 'filehist-deleteone' )->escaped(),
+                                       array(), $q
+                               );
+                       }
+                       # Link to hide content. Don't show useless link to people who cannot hide revisions.
+                       $canHide = $user->isAllowed( 'deleterevision' );
+                       if ( $canHide || ( $user->isAllowed( 'deletedhistory' ) && $file->getVisibility() ) ) {
+                               if ( $user->isAllowed( 'delete' ) ) {
+                                       $row .= '<br />';
+                               }
+                               // If file is top revision or locked from this user, don't link
+                               if ( $iscur || !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
+                                       $del = Linker::revDeleteLinkDisabled( $canHide );
+                               } else {
+                                       list( $ts, ) = explode( '!', $img, 2 );
+                                       $query = array(
+                                               'type' => 'oldimage',
+                                               'target' => $this->title->getPrefixedText(),
+                                               'ids' => $ts,
+                                       );
+                                       $del = Linker::revDeleteLink( $query,
+                                               $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
+                               }
+                               $row .= $del;
+                       }
+                       $row .= '</td>';
+               }
+
+               // Reversion link/current indicator
+               $row .= '<td>';
+               if ( $iscur ) {
+                       $row .= $this->msg( 'filehist-current' )->escaped();
+               } elseif ( $local && $this->title->quickUserCan( 'edit', $user )
+                       && $this->title->quickUserCan( 'upload', $user )
+               ) {
+                       if ( $file->isDeleted( File::DELETED_FILE ) ) {
+                               $row .= $this->msg( 'filehist-revert' )->escaped();
+                       } else {
+                               $row .= Linker::linkKnown(
+                                       $this->title,
+                                       $this->msg( 'filehist-revert' )->escaped(),
+                                       array(),
+                                       array(
+                                               'action' => 'revert',
+                                               'oldimage' => $img,
+                                               'wpEditToken' => $user->getEditToken( $img )
+                                       )
+                               );
+                       }
+               }
+               $row .= '</td>';
+
+               // Date/time and image link
+               if ( $file->getTimestamp() === $this->img->getTimestamp() ) {
+                       $selected = "class='filehistory-selected'";
+               }
+               $row .= "<td $selected style='white-space: nowrap;'>";
+               if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
+                       # Don't link to unviewable files
+                       $row .= '<span class="history-deleted">'
+                               . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
+               } elseif ( $file->isDeleted( File::DELETED_FILE ) ) {
+                       if ( $local ) {
+                               $this->preventClickjacking();
+                               $revdel = SpecialPage::getTitleFor( 'Revisiondelete' );
+                               # Make a link to review the image
+                               $url = Linker::linkKnown(
+                                       $revdel,
+                                       $lang->userTimeAndDate( $timestamp, $user ),
+                                       array(),
+                                       array(
+                                               'target' => $this->title->getPrefixedText(),
+                                               'file' => $img,
+                                               'token' => $user->getEditToken( $img )
+                                       )
+                               );
+                       } else {
+                               $url = $lang->userTimeAndDate( $timestamp, $user );
+                       }
+                       $row .= '<span class="history-deleted">' . $url . '</span>';
+               } elseif ( !$file->exists() ) {
+                       $row .= '<span class="mw-file-missing">'
+                               . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
+               } else {
+                       $url = $iscur ? $this->current->getUrl() : $this->current->getArchiveUrl( $img );
+                       $row .= Xml::element(
+                               'a',
+                               array( 'href' => $url ),
+                               $lang->userTimeAndDate( $timestamp, $user )
+                       );
+               }
+               $row .= "</td>";
+
+               // Thumbnail
+               if ( $this->showThumb ) {
+                       $row .= '<td>' . $this->getThumbForLine( $file ) . '</td>';
+               }
+
+               // Image dimensions + size
+               $row .= '<td>';
+               $row .= htmlspecialchars( $file->getDimensionsString() );
+               $row .= $this->msg( 'word-separator' )->escaped();
+               $row .= '<span style="white-space: nowrap;">';
+               $row .= $this->msg( 'parentheses' )->sizeParams( $file->getSize() )->escaped();
+               $row .= '</span>';
+               $row .= '</td>';
+
+               // Uploading user
+               $row .= '<td>';
+               // Hide deleted usernames
+               if ( $file->isDeleted( File::DELETED_USER ) ) {
+                       $row .= '<span class="history-deleted">'
+                               . $this->msg( 'rev-deleted-user' )->escaped() . '</span>';
+               } else {
+                       if ( $local ) {
+                               $row .= Linker::userLink( $userId, $userText );
+                               $row .= '<span style="white-space: nowrap;">';
+                               $row .= Linker::userToolLinks( $userId, $userText );
+                               $row .= '</span>';
+                       } else {
+                               $row .= htmlspecialchars( $userText );
+                       }
+               }
+               $row .= '</td>';
+
+               // Don't show deleted descriptions
+               if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+                       $row .= '<td><span class="history-deleted">' .
+                               $this->msg( 'rev-deleted-comment' )->escaped() . '</span></td>';
+               } else {
+                       $row .= '<td dir="' . $wgContLang->getDir() . '">' .
+                               Linker::formatComment( $description, $this->title ) . '</td>';
+               }
+
+               $rowClass = null;
+               Hooks::run( 'ImagePageFileHistoryLine', array( $this, $file, &$row, &$rowClass ) );
+               $classAttr = $rowClass ? " class='$rowClass'" : '';
+
+               return "<tr{$classAttr}>{$row}</tr>\n";
+       }
+
+       /**
+        * @param File $file
+        * @return string
+        */
+       protected function getThumbForLine( $file ) {
+               $lang = $this->getLanguage();
+               $user = $this->getUser();
+               if ( $file->allowInlineDisplay() && $file->userCan( File::DELETED_FILE, $user )
+                       && !$file->isDeleted( File::DELETED_FILE )
+               ) {
+                       $params = array(
+                               'width' => '120',
+                               'height' => '120',
+                       );
+                       $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+
+                       $thumbnail = $file->transform( $params );
+                       $options = array(
+                               'alt' => $this->msg( 'filehist-thumbtext',
+                                       $lang->userTimeAndDate( $timestamp, $user ),
+                                       $lang->userDate( $timestamp, $user ),
+                                       $lang->userTime( $timestamp, $user ) )->text(),
+                               'file-link' => true,
+                       );
+
+                       if ( !$thumbnail ) {
+                               return $this->msg( 'filehist-nothumb' )->escaped();
+                       }
+
+                       return $thumbnail->toHtml( $options );
+               } else {
+                       return $this->msg( 'filehist-nothumb' )->escaped();
+               }
+       }
+
+       /**
+        * @param bool $enable
+        */
+       protected function preventClickjacking( $enable = true ) {
+               $this->preventClickjacking = $enable;
+       }
+
+       /**
+        * @return bool
+        */
+       public function getPreventClickjacking() {
+               return $this->preventClickjacking;
+       }
+}
diff --git a/includes/page/ImageHistoryPseudoPager.php b/includes/page/ImageHistoryPseudoPager.php
new file mode 100644 (file)
index 0000000..e421d23
--- /dev/null
@@ -0,0 +1,205 @@
+<?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
+ */
+
+class ImageHistoryPseudoPager extends ReverseChronologicalPager {
+       protected $preventClickjacking = false;
+
+       /**
+        * @var File
+        */
+       protected $mImg;
+
+       /**
+        * @var Title
+        */
+       protected $mTitle;
+
+       /**
+        * @param ImagePage $imagePage
+        */
+       function __construct( $imagePage ) {
+               parent::__construct( $imagePage->getContext() );
+               $this->mImagePage = $imagePage;
+               $this->mTitle = clone $imagePage->getTitle();
+               $this->mTitle->setFragment( '#filehistory' );
+               $this->mImg = null;
+               $this->mHist = array();
+               $this->mRange = array( 0, 0 ); // display range
+       }
+
+       /**
+        * @return Title
+        */
+       function getTitle() {
+               return $this->mTitle;
+       }
+
+       function getQueryInfo() {
+               return false;
+       }
+
+       /**
+        * @return string
+        */
+       function getIndexField() {
+               return '';
+       }
+
+       /**
+        * @param object $row
+        * @return string
+        */
+       function formatRow( $row ) {
+               return '';
+       }
+
+       /**
+        * @return string
+        */
+       function getBody() {
+               $s = '';
+               $this->doQuery();
+               if ( count( $this->mHist ) ) {
+                       if ( $this->mImg->isLocal() ) {
+                               // Do a batch existence check for user pages and talkpages
+                               $linkBatch = new LinkBatch();
+                               for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+                                       $file = $this->mHist[$i];
+                                       $user = $file->getUser( 'text' );
+                                       $linkBatch->add( NS_USER, $user );
+                                       $linkBatch->add( NS_USER_TALK, $user );
+                               }
+                               $linkBatch->execute();
+                       }
+
+                       $list = new ImageHistoryList( $this->mImagePage );
+                       # Generate prev/next links
+                       $navLink = $this->getNavigationBar();
+                       $s = $list->beginImageHistoryList( $navLink );
+                       // Skip rows there just for paging links
+                       for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+                               $file = $this->mHist[$i];
+                               $s .= $list->imageHistoryLine( !$file->isOld(), $file );
+                       }
+                       $s .= $list->endImageHistoryList( $navLink );
+
+                       if ( $list->getPreventClickjacking() ) {
+                               $this->preventClickjacking();
+                       }
+               }
+               return $s;
+       }
+
+       function doQuery() {
+               if ( $this->mQueryDone ) {
+                       return;
+               }
+               $this->mImg = $this->mImagePage->getFile(); // ensure loading
+               if ( !$this->mImg->exists() ) {
+                       return;
+               }
+               $queryLimit = $this->mLimit + 1; // limit plus extra row
+               if ( $this->mIsBackwards ) {
+                       // Fetch the file history
+                       $this->mHist = $this->mImg->getHistory( $queryLimit, null, $this->mOffset, false );
+                       // The current rev may not meet the offset/limit
+                       $numRows = count( $this->mHist );
+                       if ( $numRows <= $this->mLimit && $this->mImg->getTimestamp() > $this->mOffset ) {
+                               $this->mHist = array_merge( array( $this->mImg ), $this->mHist );
+                       }
+               } else {
+                       // The current rev may not meet the offset
+                       if ( !$this->mOffset || $this->mImg->getTimestamp() < $this->mOffset ) {
+                               $this->mHist[] = $this->mImg;
+                       }
+                       // Old image versions (fetch extra row for nav links)
+                       $oiLimit = count( $this->mHist ) ? $this->mLimit : $this->mLimit + 1;
+                       // Fetch the file history
+                       $this->mHist = array_merge( $this->mHist,
+                               $this->mImg->getHistory( $oiLimit, $this->mOffset, null, false ) );
+               }
+               $numRows = count( $this->mHist ); // Total number of query results
+               if ( $numRows ) {
+                       # Index value of top item in the list
+                       $firstIndex = $this->mIsBackwards ?
+                               $this->mHist[$numRows - 1]->getTimestamp() : $this->mHist[0]->getTimestamp();
+                       # Discard the extra result row if there is one
+                       if ( $numRows > $this->mLimit && $numRows > 1 ) {
+                               if ( $this->mIsBackwards ) {
+                                       # Index value of item past the index
+                                       $this->mPastTheEndIndex = $this->mHist[0]->getTimestamp();
+                                       # Index value of bottom item in the list
+                                       $lastIndex = $this->mHist[1]->getTimestamp();
+                                       # Display range
+                                       $this->mRange = array( 1, $numRows - 1 );
+                               } else {
+                                       # Index value of item past the index
+                                       $this->mPastTheEndIndex = $this->mHist[$numRows - 1]->getTimestamp();
+                                       # Index value of bottom item in the list
+                                       $lastIndex = $this->mHist[$numRows - 2]->getTimestamp();
+                                       # Display range
+                                       $this->mRange = array( 0, $numRows - 2 );
+                               }
+                       } else {
+                               # Setting indexes to an empty string means that they will be
+                               # omitted if they would otherwise appear in URLs. It just so
+                               # happens that this  is the right thing to do in the standard
+                               # UI, in all the relevant cases.
+                               $this->mPastTheEndIndex = '';
+                               # Index value of bottom item in the list
+                               $lastIndex = $this->mIsBackwards ?
+                                       $this->mHist[0]->getTimestamp() : $this->mHist[$numRows - 1]->getTimestamp();
+                               # Display range
+                               $this->mRange = array( 0, $numRows - 1 );
+                       }
+               } else {
+                       $firstIndex = '';
+                       $lastIndex = '';
+                       $this->mPastTheEndIndex = '';
+               }
+               if ( $this->mIsBackwards ) {
+                       $this->mIsFirst = ( $numRows < $queryLimit );
+                       $this->mIsLast = ( $this->mOffset == '' );
+                       $this->mLastShown = $firstIndex;
+                       $this->mFirstShown = $lastIndex;
+               } else {
+                       $this->mIsFirst = ( $this->mOffset == '' );
+                       $this->mIsLast = ( $numRows < $queryLimit );
+                       $this->mLastShown = $lastIndex;
+                       $this->mFirstShown = $firstIndex;
+               }
+               $this->mQueryDone = true;
+       }
+
+       /**
+        * @param bool $enable
+        */
+       protected function preventClickjacking( $enable = true ) {
+               $this->preventClickjacking = $enable;
+       }
+
+       /**
+        * @return bool
+        */
+       public function getPreventClickjacking() {
+               return $this->preventClickjacking;
+       }
+
+}
index 0ba1328..d171e89 100644 (file)
@@ -1242,497 +1242,3 @@ EOT
        }
 
 }
-
-/**
- * Builds the image revision log shown on image pages
- *
- * @ingroup Media
- */
-class ImageHistoryList extends ContextSource {
-
-       /**
-        * @var Title
-        */
-       protected $title;
-
-       /**
-        * @var File
-        */
-       protected $img;
-
-       /**
-        * @var ImagePage
-        */
-       protected $imagePage;
-
-       /**
-        * @var File
-        */
-       protected $current;
-
-       protected $repo, $showThumb;
-       protected $preventClickjacking = false;
-
-       /**
-        * @param ImagePage $imagePage
-        */
-       public function __construct( $imagePage ) {
-               global $wgShowArchiveThumbnails;
-               $this->current = $imagePage->getFile();
-               $this->img = $imagePage->getDisplayedFile();
-               $this->title = $imagePage->getTitle();
-               $this->imagePage = $imagePage;
-               $this->showThumb = $wgShowArchiveThumbnails && $this->img->canRender();
-               $this->setContext( $imagePage->getContext() );
-       }
-
-       /**
-        * @return ImagePage
-        */
-       public function getImagePage() {
-               return $this->imagePage;
-       }
-
-       /**
-        * @return File
-        */
-       public function getFile() {
-               return $this->img;
-       }
-
-       /**
-        * @param string $navLinks
-        * @return string
-        */
-       public function beginImageHistoryList( $navLinks = '' ) {
-               return Xml::element( 'h2', array( 'id' => 'filehistory' ), $this->msg( 'filehist' )->text() )
-                       . "\n"
-                       . "<div id=\"mw-imagepage-section-filehistory\">\n"
-                       . $this->msg( 'filehist-help' )->parseAsBlock()
-                       . $navLinks . "\n"
-                       . Xml::openElement( 'table', array( 'class' => 'wikitable filehistory' ) ) . "\n"
-                       . '<tr><th></th>'
-                       . ( $this->current->isLocal()
-                               && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<th></th>' : '' )
-                       . '<th>' . $this->msg( 'filehist-datetime' )->escaped() . '</th>'
-                       . ( $this->showThumb ? '<th>' . $this->msg( 'filehist-thumb' )->escaped() . '</th>' : '' )
-                       . '<th>' . $this->msg( 'filehist-dimensions' )->escaped() . '</th>'
-                       . '<th>' . $this->msg( 'filehist-user' )->escaped() . '</th>'
-                       . '<th>' . $this->msg( 'filehist-comment' )->escaped() . '</th>'
-                       . "</tr>\n";
-       }
-
-       /**
-        * @param string $navLinks
-        * @return string
-        */
-       public function endImageHistoryList( $navLinks = '' ) {
-               return "</table>\n$navLinks\n</div>\n";
-       }
-
-       /**
-        * @param bool $iscur
-        * @param File $file
-        * @return string
-        */
-       public function imageHistoryLine( $iscur, $file ) {
-               global $wgContLang;
-
-               $user = $this->getUser();
-               $lang = $this->getLanguage();
-               $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
-               $img = $iscur ? $file->getName() : $file->getArchiveName();
-               $userId = $file->getUser( 'id' );
-               $userText = $file->getUser( 'text' );
-               $description = $file->getDescription( File::FOR_THIS_USER, $user );
-
-               $local = $this->current->isLocal();
-               $row = $selected = '';
-
-               // Deletion link
-               if ( $local && ( $user->isAllowedAny( 'delete', 'deletedhistory' ) ) ) {
-                       $row .= '<td>';
-                       # Link to remove from history
-                       if ( $user->isAllowed( 'delete' ) ) {
-                               $q = array( 'action' => 'delete' );
-                               if ( !$iscur ) {
-                                       $q['oldimage'] = $img;
-                               }
-                               $row .= Linker::linkKnown(
-                                       $this->title,
-                                       $this->msg( $iscur ? 'filehist-deleteall' : 'filehist-deleteone' )->escaped(),
-                                       array(), $q
-                               );
-                       }
-                       # Link to hide content. Don't show useless link to people who cannot hide revisions.
-                       $canHide = $user->isAllowed( 'deleterevision' );
-                       if ( $canHide || ( $user->isAllowed( 'deletedhistory' ) && $file->getVisibility() ) ) {
-                               if ( $user->isAllowed( 'delete' ) ) {
-                                       $row .= '<br />';
-                               }
-                               // If file is top revision or locked from this user, don't link
-                               if ( $iscur || !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
-                                       $del = Linker::revDeleteLinkDisabled( $canHide );
-                               } else {
-                                       list( $ts, ) = explode( '!', $img, 2 );
-                                       $query = array(
-                                               'type' => 'oldimage',
-                                               'target' => $this->title->getPrefixedText(),
-                                               'ids' => $ts,
-                                       );
-                                       $del = Linker::revDeleteLink( $query,
-                                               $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
-                               }
-                               $row .= $del;
-                       }
-                       $row .= '</td>';
-               }
-
-               // Reversion link/current indicator
-               $row .= '<td>';
-               if ( $iscur ) {
-                       $row .= $this->msg( 'filehist-current' )->escaped();
-               } elseif ( $local && $this->title->quickUserCan( 'edit', $user )
-                       && $this->title->quickUserCan( 'upload', $user )
-               ) {
-                       if ( $file->isDeleted( File::DELETED_FILE ) ) {
-                               $row .= $this->msg( 'filehist-revert' )->escaped();
-                       } else {
-                               $row .= Linker::linkKnown(
-                                       $this->title,
-                                       $this->msg( 'filehist-revert' )->escaped(),
-                                       array(),
-                                       array(
-                                               'action' => 'revert',
-                                               'oldimage' => $img,
-                                               'wpEditToken' => $user->getEditToken( $img )
-                                       )
-                               );
-                       }
-               }
-               $row .= '</td>';
-
-               // Date/time and image link
-               if ( $file->getTimestamp() === $this->img->getTimestamp() ) {
-                       $selected = "class='filehistory-selected'";
-               }
-               $row .= "<td $selected style='white-space: nowrap;'>";
-               if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
-                       # Don't link to unviewable files
-                       $row .= '<span class="history-deleted">'
-                               . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
-               } elseif ( $file->isDeleted( File::DELETED_FILE ) ) {
-                       if ( $local ) {
-                               $this->preventClickjacking();
-                               $revdel = SpecialPage::getTitleFor( 'Revisiondelete' );
-                               # Make a link to review the image
-                               $url = Linker::linkKnown(
-                                       $revdel,
-                                       $lang->userTimeAndDate( $timestamp, $user ),
-                                       array(),
-                                       array(
-                                               'target' => $this->title->getPrefixedText(),
-                                               'file' => $img,
-                                               'token' => $user->getEditToken( $img )
-                                       )
-                               );
-                       } else {
-                               $url = $lang->userTimeAndDate( $timestamp, $user );
-                       }
-                       $row .= '<span class="history-deleted">' . $url . '</span>';
-               } elseif ( !$file->exists() ) {
-                       $row .= '<span class="mw-file-missing">'
-                               . $lang->userTimeAndDate( $timestamp, $user ) . '</span>';
-               } else {
-                       $url = $iscur ? $this->current->getUrl() : $this->current->getArchiveUrl( $img );
-                       $row .= Xml::element(
-                               'a',
-                               array( 'href' => $url ),
-                               $lang->userTimeAndDate( $timestamp, $user )
-                       );
-               }
-               $row .= "</td>";
-
-               // Thumbnail
-               if ( $this->showThumb ) {
-                       $row .= '<td>' . $this->getThumbForLine( $file ) . '</td>';
-               }
-
-               // Image dimensions + size
-               $row .= '<td>';
-               $row .= htmlspecialchars( $file->getDimensionsString() );
-               $row .= $this->msg( 'word-separator' )->escaped();
-               $row .= '<span style="white-space: nowrap;">';
-               $row .= $this->msg( 'parentheses' )->sizeParams( $file->getSize() )->escaped();
-               $row .= '</span>';
-               $row .= '</td>';
-
-               // Uploading user
-               $row .= '<td>';
-               // Hide deleted usernames
-               if ( $file->isDeleted( File::DELETED_USER ) ) {
-                       $row .= '<span class="history-deleted">'
-                               . $this->msg( 'rev-deleted-user' )->escaped() . '</span>';
-               } else {
-                       if ( $local ) {
-                               $row .= Linker::userLink( $userId, $userText );
-                               $row .= '<span style="white-space: nowrap;">';
-                               $row .= Linker::userToolLinks( $userId, $userText );
-                               $row .= '</span>';
-                       } else {
-                               $row .= htmlspecialchars( $userText );
-                       }
-               }
-               $row .= '</td>';
-
-               // Don't show deleted descriptions
-               if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
-                       $row .= '<td><span class="history-deleted">' .
-                               $this->msg( 'rev-deleted-comment' )->escaped() . '</span></td>';
-               } else {
-                       $row .= '<td dir="' . $wgContLang->getDir() . '">' .
-                               Linker::formatComment( $description, $this->title ) . '</td>';
-               }
-
-               $rowClass = null;
-               Hooks::run( 'ImagePageFileHistoryLine', array( $this, $file, &$row, &$rowClass ) );
-               $classAttr = $rowClass ? " class='$rowClass'" : '';
-
-               return "<tr{$classAttr}>{$row}</tr>\n";
-       }
-
-       /**
-        * @param File $file
-        * @return string
-        */
-       protected function getThumbForLine( $file ) {
-               $lang = $this->getLanguage();
-               $user = $this->getUser();
-               if ( $file->allowInlineDisplay() && $file->userCan( File::DELETED_FILE, $user )
-                       && !$file->isDeleted( File::DELETED_FILE )
-               ) {
-                       $params = array(
-                               'width' => '120',
-                               'height' => '120',
-                       );
-                       $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
-
-                       $thumbnail = $file->transform( $params );
-                       $options = array(
-                               'alt' => $this->msg( 'filehist-thumbtext',
-                                       $lang->userTimeAndDate( $timestamp, $user ),
-                                       $lang->userDate( $timestamp, $user ),
-                                       $lang->userTime( $timestamp, $user ) )->text(),
-                               'file-link' => true,
-                       );
-
-                       if ( !$thumbnail ) {
-                               return $this->msg( 'filehist-nothumb' )->escaped();
-                       }
-
-                       return $thumbnail->toHtml( $options );
-               } else {
-                       return $this->msg( 'filehist-nothumb' )->escaped();
-               }
-       }
-
-       /**
-        * @param bool $enable
-        */
-       protected function preventClickjacking( $enable = true ) {
-               $this->preventClickjacking = $enable;
-       }
-
-       /**
-        * @return bool
-        */
-       public function getPreventClickjacking() {
-               return $this->preventClickjacking;
-       }
-}
-
-class ImageHistoryPseudoPager extends ReverseChronologicalPager {
-       protected $preventClickjacking = false;
-
-       /**
-        * @var File
-        */
-       protected $mImg;
-
-       /**
-        * @var Title
-        */
-       protected $mTitle;
-
-       /**
-        * @param ImagePage $imagePage
-        */
-       function __construct( $imagePage ) {
-               parent::__construct( $imagePage->getContext() );
-               $this->mImagePage = $imagePage;
-               $this->mTitle = clone $imagePage->getTitle();
-               $this->mTitle->setFragment( '#filehistory' );
-               $this->mImg = null;
-               $this->mHist = array();
-               $this->mRange = array( 0, 0 ); // display range
-       }
-
-       /**
-        * @return Title
-        */
-       function getTitle() {
-               return $this->mTitle;
-       }
-
-       function getQueryInfo() {
-               return false;
-       }
-
-       /**
-        * @return string
-        */
-       function getIndexField() {
-               return '';
-       }
-
-       /**
-        * @param object $row
-        * @return string
-        */
-       function formatRow( $row ) {
-               return '';
-       }
-
-       /**
-        * @return string
-        */
-       function getBody() {
-               $s = '';
-               $this->doQuery();
-               if ( count( $this->mHist ) ) {
-                       if ( $this->mImg->isLocal() ) {
-                               // Do a batch existence check for user pages and talkpages
-                               $linkBatch = new LinkBatch();
-                               for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
-                                       $file = $this->mHist[$i];
-                                       $user = $file->getUser( 'text' );
-                                       $linkBatch->add( NS_USER, $user );
-                                       $linkBatch->add( NS_USER_TALK, $user );
-                               }
-                               $linkBatch->execute();
-                       }
-
-                       $list = new ImageHistoryList( $this->mImagePage );
-                       # Generate prev/next links
-                       $navLink = $this->getNavigationBar();
-                       $s = $list->beginImageHistoryList( $navLink );
-                       // Skip rows there just for paging links
-                       for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
-                               $file = $this->mHist[$i];
-                               $s .= $list->imageHistoryLine( !$file->isOld(), $file );
-                       }
-                       $s .= $list->endImageHistoryList( $navLink );
-
-                       if ( $list->getPreventClickjacking() ) {
-                               $this->preventClickjacking();
-                       }
-               }
-               return $s;
-       }
-
-       function doQuery() {
-               if ( $this->mQueryDone ) {
-                       return;
-               }
-               $this->mImg = $this->mImagePage->getFile(); // ensure loading
-               if ( !$this->mImg->exists() ) {
-                       return;
-               }
-               $queryLimit = $this->mLimit + 1; // limit plus extra row
-               if ( $this->mIsBackwards ) {
-                       // Fetch the file history
-                       $this->mHist = $this->mImg->getHistory( $queryLimit, null, $this->mOffset, false );
-                       // The current rev may not meet the offset/limit
-                       $numRows = count( $this->mHist );
-                       if ( $numRows <= $this->mLimit && $this->mImg->getTimestamp() > $this->mOffset ) {
-                               $this->mHist = array_merge( array( $this->mImg ), $this->mHist );
-                       }
-               } else {
-                       // The current rev may not meet the offset
-                       if ( !$this->mOffset || $this->mImg->getTimestamp() < $this->mOffset ) {
-                               $this->mHist[] = $this->mImg;
-                       }
-                       // Old image versions (fetch extra row for nav links)
-                       $oiLimit = count( $this->mHist ) ? $this->mLimit : $this->mLimit + 1;
-                       // Fetch the file history
-                       $this->mHist = array_merge( $this->mHist,
-                               $this->mImg->getHistory( $oiLimit, $this->mOffset, null, false ) );
-               }
-               $numRows = count( $this->mHist ); // Total number of query results
-               if ( $numRows ) {
-                       # Index value of top item in the list
-                       $firstIndex = $this->mIsBackwards ?
-                               $this->mHist[$numRows - 1]->getTimestamp() : $this->mHist[0]->getTimestamp();
-                       # Discard the extra result row if there is one
-                       if ( $numRows > $this->mLimit && $numRows > 1 ) {
-                               if ( $this->mIsBackwards ) {
-                                       # Index value of item past the index
-                                       $this->mPastTheEndIndex = $this->mHist[0]->getTimestamp();
-                                       # Index value of bottom item in the list
-                                       $lastIndex = $this->mHist[1]->getTimestamp();
-                                       # Display range
-                                       $this->mRange = array( 1, $numRows - 1 );
-                               } else {
-                                       # Index value of item past the index
-                                       $this->mPastTheEndIndex = $this->mHist[$numRows - 1]->getTimestamp();
-                                       # Index value of bottom item in the list
-                                       $lastIndex = $this->mHist[$numRows - 2]->getTimestamp();
-                                       # Display range
-                                       $this->mRange = array( 0, $numRows - 2 );
-                               }
-                       } else {
-                               # Setting indexes to an empty string means that they will be
-                               # omitted if they would otherwise appear in URLs. It just so
-                               # happens that this  is the right thing to do in the standard
-                               # UI, in all the relevant cases.
-                               $this->mPastTheEndIndex = '';
-                               # Index value of bottom item in the list
-                               $lastIndex = $this->mIsBackwards ?
-                                       $this->mHist[0]->getTimestamp() : $this->mHist[$numRows - 1]->getTimestamp();
-                               # Display range
-                               $this->mRange = array( 0, $numRows - 1 );
-                       }
-               } else {
-                       $firstIndex = '';
-                       $lastIndex = '';
-                       $this->mPastTheEndIndex = '';
-               }
-               if ( $this->mIsBackwards ) {
-                       $this->mIsFirst = ( $numRows < $queryLimit );
-                       $this->mIsLast = ( $this->mOffset == '' );
-                       $this->mLastShown = $firstIndex;
-                       $this->mFirstShown = $lastIndex;
-               } else {
-                       $this->mIsFirst = ( $this->mOffset == '' );
-                       $this->mIsLast = ( $numRows < $queryLimit );
-                       $this->mLastShown = $lastIndex;
-                       $this->mFirstShown = $firstIndex;
-               }
-               $this->mQueryDone = true;
-       }
-
-       /**
-        * @param bool $enable
-        */
-       protected function preventClickjacking( $enable = true ) {
-               $this->preventClickjacking = $enable;
-       }
-
-       /**
-        * @return bool
-        */
-       public function getPreventClickjacking() {
-               return $this->preventClickjacking;
-       }
-
-}
index 817f153..4ca3a87 100644 (file)
@@ -237,8 +237,6 @@ class Preprocessor_DOM extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
-               // Map of tag name => true if there are no more closing tags of given type right of $i
-               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -459,21 +457,17 @@ class Preprocessor_DOM extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if (
-                                               !isset( $noMoreClosingTag[$name] ) &&
-                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = '<close>' . htmlspecialchars( $matches[0][0] ) . '</close>';
                                        } else {
-                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
-                                               $i = $tagEndPos + 1;
-                                               $accum .= htmlspecialchars( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
-                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
-                                               $noMoreClosingTag[$name] = true;
-                                               continue;
+                                               // No end tag -- let it run out to the end of the text.
+                                               $inner = substr( $text, $tagEndPos + 1 );
+                                               $i = $lengthText;
+                                               $close = '';
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index 28c49fd..50eaefb 100644 (file)
@@ -160,8 +160,6 @@ class Preprocessor_Hash extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
-               // Map of tag name => true if there are no more closing tags of given type right of $i
-               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -382,21 +380,17 @@ class Preprocessor_Hash extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if (
-                                               !isset( $noMoreClosingTag[$name] ) &&
-                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = $matches[0][0];
                                        } else {
-                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
-                                               $i = $tagEndPos + 1;
-                                               $accum->addLiteral( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
-                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
-                                               $noMoreClosingTag[$name] = true;
-                                               continue;
+                                               // No end tag -- let it run out to the end of the text.
+                                               $inner = substr( $text, $tagEndPos + 1 );
+                                               $i = $lengthText;
+                                               $close = null;
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index 3c8d56e..81b850a 100644 (file)
@@ -296,6 +296,15 @@ class SearchEngine {
         * @param int[]|null $namespaces
         */
        function setNamespaces( $namespaces ) {
+               if ( $namespaces ) {
+                       // Filter namespaces to only keep valid ones
+                       $validNs = $this->searchableNamespaces();
+                       $namespaces = array_filter( $namespaces, function( $ns ) use( $validNs ) {
+                               return $ns < 0 || isset( $validNs[$ns] );
+                       } );
+               } else {
+                       $namespaces = array();
+               }
                $this->namespaces = $namespaces;
        }
 
@@ -570,6 +579,201 @@ class SearchEngine {
        public function textAlreadyUpdatedForIndex() {
                return false;
        }
+
+       /**
+        * Makes search simple string if it was namespaced.
+        * Sets namespaces of the search to namespaces extracted from string.
+        * @param string $search
+        * @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 = array( $title->getNamespace() );
+                       $search = $title->getText();
+                       if ( $ns[0] == NS_MAIN ) {
+                               $ns = $this->namespaces; // no explicit prefix, use default namespaces
+                               Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
+                       }
+               } else {
+                       $title = Title::newFromText( $search . 'Dummy' );
+                       if ( $title && $title->getText() == 'Dummy'
+                                       && $title->getNamespace() != NS_MAIN
+                                       && !$title->isExternal() )
+                       {
+                               $ns = array( $title->getNamespace() );
+                               $search = '';
+                       } else {
+                               Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
+                       }
+               }
+
+               $ns = array_map( function( $space ) {
+                       return $space == NS_MEDIA ? NS_FILE : $space;
+               }, $ns );
+
+               $this->setNamespaces( $ns );
+               return $search;
+       }
+
+       /**
+        * Perform a completion search.
+        * Does not resolve namespaces and does not check variants.
+        * Search engine implementations may want to override this function.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       protected function completionSearchBackend( $search ) {
+               $results = array();
+
+               $search = trim( $search );
+
+               if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
+                        !Hooks::run( 'PrefixSearchBackend',
+                               array( $this->namespaces, $search, $this->limit, &$results, $this->offset )
+               ) ) {
+                       // False means hook worked.
+                       // FIXME: Yes, the API is weird. That's why it is going to be deprecated.
+
+                       return SearchSuggestionSet::fromStrings( $results );
+               } else {
+                       // Hook did not do the job, use default simple search
+                       $results = $this->simplePrefixSearch( $search );
+                       return SearchSuggestionSet::fromTitles( $results );
+               }
+       }
+
+       /**
+        * Perform a completion search.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+               return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+       }
+
+       /**
+        * Perform a completion search with variants.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearchWithVariants( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+
+               $results = $this->completionSearchBackend( $search );
+               $fallbackLimit = $this->limit - $results->getSize();
+               if ( $fallbackLimit > 0 ) {
+                       global $wgContLang;
+
+                       $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+                       $fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) );
+
+                       foreach ( $fallbackSearches as $fbs ) {
+                               $this->setLimitOffset( $fallbackLimit );
+                               $fallbackSearchResult = $this->completionSearch( $fbs );
+                               $results->appendAll( $fallbackSearchResult );
+                               $fallbackLimit -= count( $fallbackSearchResult );
+                               if ( $fallbackLimit <= 0 ) {
+                                       break;
+                               }
+                       }
+               }
+               return $this->processCompletionResults( $search, $results );
+       }
+
+       /**
+        * Extract titles from completion results
+        * @param SearchSuggestionSet $completionResults
+        * @return Title[]
+        */
+       public function extractTitles( SearchSuggestionSet $completionResults ) {
+               return $completionResults->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+       }
+
+       /**
+        * Process completion search results.
+        * Resolves the titles and rescores.
+        * @param SearchSuggestionSet $suggestions
+        * @return SearchSuggestionSet
+        */
+       protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+               if ( $suggestions->getSize() == 0 ) {
+                       // If we don't have anything, don't bother
+                       return $suggestions;
+               }
+               $search = trim( $search );
+               // preload the titles with LinkBatch
+               $titles = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+               $lb = new LinkBatch( $titles );
+               $lb->setCaller( __METHOD__ );
+               $lb->execute();
+
+               $results = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle()->getPrefixedText();
+               } );
+
+               // Rescore results with an exact title match
+               $rescorer = new SearchExactMatchRescorer();
+               $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
+
+               if ( count( $rescoredResults ) > 0 ) {
+                       $found = array_search( $rescoredResults[0], $results );
+                       if ( $found === false ) {
+                               // If the first result is not in the previous array it
+                               // means that we found a new exact match
+                               $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
+                               $suggestions->prepend( $exactMatch );
+                               $suggestions->shrink( $this->limit );
+                       } else {
+                               // if the first result is not the same we need to rescore
+                               if ( $found > 0 ) {
+                                       $suggestions->rescore( $found );
+                               }
+                       }
+               }
+
+               return $suggestions;
+       }
+
+       /**
+        * Simple prefix search for subpages.
+        * @param string $search
+        * @return Title[]
+        */
+       public function defaultPrefixSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return array();
+               }
+
+               $search = $this->normalizeNamespaces( $search );
+               return $this->simplePrefixSearch( $search );
+       }
+
+       /**
+        * Call out to simple search backend.
+        * Defaults to TitlePrefixSearch.
+        * @param string $search
+        * @return Title[]
+        */
+       protected function simplePrefixSearch( $search ) {
+               // Use default database prefix search
+               $backend = new TitlePrefixSearch;
+               return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
+       }
+
 }
 
 /**
diff --git a/includes/search/SearchSuggestion.php b/includes/search/SearchSuggestion.php
new file mode 100644 (file)
index 0000000..cd9062b
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+
+/**
+ * Search suggestion
+ *
+ * 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
+ *
+ */
+
+/**
+ * A search suggestion
+ *
+ */
+class SearchSuggestion {
+       /**
+        * @var string the suggestion
+        */
+       private $text;
+
+       /**
+        * @var string the suggestion URL
+        */
+       private $url;
+
+       /**
+        * @var Title|null the suggested title
+        */
+       private $suggestedTitle;
+
+       /**
+        * NOTE: even if suggestedTitle is a redirect suggestedTitleID
+        * is the ID of the target page.
+        * @var int|null the suggested title ID
+        */
+       private $suggestedTitleID;
+
+       /**
+        * @var float|null The suggestion score
+        */
+       private $score;
+
+       /**
+        * Construct a new suggestion
+        * @param float $score the suggestion score
+        * @param string $text|null the suggestion text
+        * @param Title|null $suggestedTitle the suggested title
+        * @param int|null $suggestedTitleID the suggested title ID
+        */
+       public function __construct( $score, $text = null, Title $suggestedTitle = null,
+                       $suggestedTitleID = null ) {
+               $this->score = $score;
+               $this->text = $text;
+               if ( $suggestedTitle ) {
+                       $this->setSuggestedTitle( $suggestedTitle );
+               }
+               $this->suggestedTitleID = $suggestedTitleID;
+       }
+
+       /**
+        * The suggestion text
+        * @return string
+        */
+       public function getText() {
+               return $this->text;
+       }
+
+       /**
+        * Set the suggestion text.
+        * @param string $text
+        * @param bool $setTitle Should we also update the title?
+        */
+       public function setText( $text, $setTitle = true ) {
+               $this->text = $text;
+               if ( $setTitle && $text ) {
+                       $this->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+               }
+       }
+
+       /**
+        * Title object in the case this suggestion is based on a title.
+        * May return null if the suggestion is not a Title.
+        * @return Title|null
+        */
+       public function getSuggestedTitle() {
+               return $this->suggestedTitle;
+       }
+
+       /**
+        * Set the suggested title
+        * @param Title|null $title
+        */
+       public function setSuggestedTitle( Title $title = null ) {
+               $this->suggestedTitle = $title;
+               if ( $title !== null ) {
+                       $this->url = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+               }
+       }
+
+       /**
+        * Title ID in the case this suggestion is based on a title.
+        * May return null if the suggestion is not a Title.
+        * @return int|null
+        */
+       public function getSuggestedTitleID() {
+               return $this->suggestedTitleID;
+       }
+
+       /**
+        * Set the suggested title ID
+        * @param int|null $suggestedTitleID
+        */
+       public function setSuggestedTitleID( $suggestedTitleID = null ) {
+               $this->suggestedTitleID = $suggestedTitleID;
+       }
+
+       /**
+        * Suggestion score
+        * @return float Suggestion score
+        */
+       public function getScore() {
+               return $this->score;
+       }
+
+       /**
+        * Set the suggestion score
+        * @param float $score
+        */
+       public function setScore( $score ) {
+               $this->score = $score;
+       }
+
+       /**
+        * Suggestion URL, can be the link to the Title or maybe in the
+        * future a link to the search results for this search suggestion.
+        * @return string Suggestion URL
+        */
+       public function getURL() {
+               return $this->url;
+       }
+
+       /**
+        * Set the suggestion URL
+        * @param string $url
+        */
+       public function setURL( $url ) {
+               $this->url = $url;
+       }
+
+       /**
+        * Create suggestion from Title
+        * @param float $score Suggestions score
+        * @param Title $title
+        * @return SearchSuggestion
+        */
+       public static function fromTitle( $score, Title $title ) {
+               return new self( $score, $title->getPrefixedText(), $title, $title->getArticleID() );
+       }
+
+       /**
+        * Create suggestion from text
+        * Will also create a title if text if not empty.
+        * @param float $score Suggestions score
+        * @param string $text
+        * @return SearchSuggestion
+        */
+       public static function fromText( $score, $text ) {
+               $suggestion = new self( $score, $text );
+               if ( $text ) {
+                       $suggestion->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+               }
+               return $suggestion;
+       }
+
+}
diff --git a/includes/search/SearchSuggestionSet.php b/includes/search/SearchSuggestionSet.php
new file mode 100644 (file)
index 0000000..a1f9a04
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+
+/**
+ * Search suggestion sets
+ *
+ * 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
+ *
+ */
+
+/**
+ * A set of search suggestions.
+ * The set is always ordered by score, with the best match first.
+ */
+class SearchSuggestionSet {
+       /**
+        * @var SearchSuggestion[]
+        */
+       private $suggestions = array();
+
+       /**
+        *
+        * @var array
+        */
+       private $pageMap = array();
+
+       /**
+        * Builds a new set of suggestions.
+        *
+        * NOTE: the array should be sorted by score (higher is better),
+        * in descending order.
+        * SearchSuggestionSet will not try to re-order this input array.
+        * Providing an unsorted input array is a mistake and will lead to
+        * unexpected behaviors.
+        *
+        * @param SearchSuggestion[] $suggestions (must be sorted by score)
+        */
+       public function __construct( array $suggestions ) {
+               foreach ( $suggestions as $suggestion ) {
+                       $pageID = $suggestion->getSuggestedTitleID();
+                       if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
+                               $this->pageMap[$pageID] = true;
+                       }
+                       $this->suggestions[] = $suggestion;
+               }
+       }
+
+       /**
+        * Get the list of suggestions.
+        * @return SearchSuggestion[]
+        */
+       public function getSuggestions() {
+               return $this->suggestions;
+       }
+
+       /**
+        * Call array_map on the suggestions array
+        * @param callback $callback
+        * @return array
+        */
+       public function map( $callback ) {
+               return array_map( $callback, $this->suggestions );
+       }
+
+       /**
+        * Add a new suggestion at the end.
+        * If the score of the new suggestion is greater than the worst one,
+        * the new suggestion score will be updated (worst - 1).
+        *
+        * @param SearchSuggestion $suggestion
+        */
+       public function append( SearchSuggestion $suggestion ) {
+               $pageID = $suggestion->getSuggestedTitleID();
+               if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+                       return;
+               }
+               if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) {
+                       $suggestion->setScore( $this->getWorstScore() - 1 );
+               }
+               $this->suggestions[] = $suggestion;
+               if ( $pageID ) {
+                       $this->pageMap[$pageID] = true;
+               }
+       }
+
+       /**
+        * Add suggestion set to the end of the current one.
+        * @param SearchSuggestionSet $set
+        */
+       public function appendAll( SearchSuggestionSet $set ) {
+               foreach ( $set->getSuggestions() as $sugg ) {
+                       $this->append( $sugg );
+               }
+       }
+
+       /**
+        * Move the suggestion at index $key to the first position
+        */
+       public function rescore( $key ) {
+               $removed = array_splice( $this->suggestions, $key, 1 );
+               unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] );
+               $this->prepend( $removed[0] );
+       }
+
+       /**
+        * Add a new suggestion at the top. If the new suggestion score
+        * is lower than the best one its score will be updated (best + 1)
+        * @param SearchSuggestion $suggestion
+        */
+       public function prepend( SearchSuggestion $suggestion ) {
+               $pageID = $suggestion->getSuggestedTitleID();
+               if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+                       return;
+               }
+               if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) {
+                       $suggestion->setScore( $this->getBestScore() + 1 );
+               }
+               array_unshift( $this->suggestions,  $suggestion );
+               if ( $pageID ) {
+                       $this->pageMap[$pageID] = true;
+               }
+       }
+
+       /**
+        * @return float the best score in this suggestion set
+        */
+       public function getBestScore() {
+               if ( empty( $this->suggestions ) ) {
+                       return 0;
+               }
+               return $this->suggestions[0]->getScore();
+       }
+
+       /**
+        * @return float the worst score in this set
+        */
+       public function getWorstScore() {
+               if ( empty( $this->suggestions ) ) {
+                       return 0;
+               }
+               return end( $this->suggestions )->getScore();
+       }
+
+       /**
+        * @return int the number of suggestion in this set
+        */
+       public function getSize() {
+               return count( $this->suggestions );
+       }
+
+       /**
+        * Remove any extra elements in the suggestions set
+        * @param int $limit the max size of this set.
+        */
+       public function shrink( $limit ) {
+               if ( count( $this->suggestions ) > $limit ) {
+                       $this->suggestions = array_slice( $this->suggestions, 0, $limit );
+               }
+       }
+
+       /**
+        * Builds a new set of suggestion based on a title array.
+        * Useful when using a backend that supports only Titles.
+        *
+        * NOTE: Suggestion scores will be generated.
+        *
+        * @param Title[] $titles
+        * @return SearchSuggestionSet
+        */
+       public static function fromTitles( array $titles ) {
+               $score = count( $titles );
+               $suggestions = array_map( function( $title ) use ( &$score ) {
+                       return SearchSuggestion::fromTitle( $score--, $title );
+               }, $titles );
+               return new SearchSuggestionSet( $suggestions );
+       }
+
+       /**
+        * Builds a new set of suggestion based on a string array.
+        *
+        * NOTE: Suggestion scores will be generated.
+        *
+        * @param string[] $titles
+        * @return SearchSuggestionSet
+        */
+       public static function fromStrings( array $titles ) {
+               $score = count( $titles );
+               $suggestions = array_map( function( $title ) use ( &$score ) {
+                       return SearchSuggestion::fromText( $score--, $title );
+               }, $titles );
+               return new SearchSuggestionSet( $suggestions );
+       }
+
+
+       /**
+        * @return SearchSuggestionSet an empty suggestion set
+        */
+       public static function emptySuggestionSet() {
+               return new SearchSuggestionSet( array() );
+       }
+}
index d21bea9..4dea274 100644 (file)
@@ -123,6 +123,12 @@ class PHPSessionHandler {
                ini_set( 'session.use_cookies', 0 );
                ini_set( 'session.use_trans_sid', 0 );
 
+               // T124510: Disable automatic PHP session related cache headers.
+               // MediaWiki adds it's own headers and the default PHP behavior may
+               // set headers such as 'Pragma: no-cache' that cause problems with
+               // some user agents.
+               session_cache_limiter( '' );
+
                // Also set a sane serialization handler
                \Wikimedia\PhpSessionSerializer::setSerializeHandler();
 
index 2a13ed2..2bff173 100644 (file)
@@ -23,7 +23,7 @@
 
 namespace MediaWiki\Session;
 
-use BagOStuff;
+use CachedBagOStuff;
 use Psr\Log\LoggerInterface;
 use User;
 use WebRequest;
@@ -64,10 +64,8 @@ final class SessionBackend {
        /** @var string Used to detect subarray modifications */
        private $dataHash = null;
 
-       /** @var BagOStuff */
-       private $tempStore;
-       /** @var BagOStuff */
-       private $permStore;
+       /** @var CachedBagOStuff */
+       private $store;
 
        /** @var LoggerInterface */
        private $logger;
@@ -99,14 +97,12 @@ final class SessionBackend {
        /**
         * @param SessionId $id Session ID object
         * @param SessionInfo $info Session info to populate from
-        * @param BagOStuff $tempStore In-process data store
-        * @param BagOStuff $permstore Backend data store for persisted sessions
+        * @param CachedBagOStuff $store Backend data store
         * @param LoggerInterface $logger
         * @param int $lifetime Session data lifetime in seconds
         */
        public function __construct(
-               SessionId $id, SessionInfo $info, BagOStuff $tempStore, BagOStuff $permStore,
-               LoggerInterface $logger, $lifetime
+               SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger, $lifetime
        ) {
                $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
                $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
@@ -125,8 +121,7 @@ final class SessionBackend {
 
                $this->id = $id;
                $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
-               $this->tempStore = $tempStore;
-               $this->permStore = $permStore;
+               $this->store = $store;
                $this->logger = $logger;
                $this->lifetime = $lifetime;
                $this->provider = $info->getProvider();
@@ -135,14 +130,7 @@ final class SessionBackend {
                $this->forceHTTPS = $info->forceHTTPS();
                $this->providerMetadata = $info->getProviderMetadata();
 
-               $key = wfMemcKey( 'MWSession', (string)$this->id );
-               $blob = $tempStore->get( $key );
-               if ( $blob === false ) {
-                       $blob = $permStore->get( $key );
-                       if ( $blob !== false ) {
-                               $tempStore->set( $key, $blob );
-                       }
-               }
+               $blob = $store->get( wfMemcKey( 'MWSession', (string)$this->id ) );
                if ( !is_array( $blob ) ||
                        !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
                        !isset( $blob['data'] ) || !is_array( $blob['data'] )
@@ -241,8 +229,7 @@ final class SessionBackend {
                        $this->autosave();
 
                        // Delete the data for the old session ID now
-                       $this->tempStore->delete( wfMemcKey( 'MWSession', $oldId ) );
-                       $this->permStore->delete( wfMemcKey( 'MWSession', $oldId ) );
+                       $this->store->delete( wfMemcKey( 'MWSession', $oldId ) );
                }
        }
 
@@ -626,24 +613,15 @@ final class SessionBackend {
                        }
                }
 
-               $this->tempStore->set(
+               $this->store->set(
                        wfMemcKey( 'MWSession', (string)$this->id ),
                        array(
                                'data' => $this->data,
                                'metadata' => $metadata,
                        ),
-                       $metadata['expires']
+                       $metadata['expires'],
+                       $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY
                );
-               if ( $this->persist ) {
-                       $this->permStore->set(
-                               wfMemcKey( 'MWSession', (string)$this->id ),
-                               array(
-                                       'data' => $this->data,
-                                       'metadata' => $metadata,
-                               ),
-                               $metadata['expires']
-                       );
-               }
 
                $this->metaDirty = false;
                $this->dataDirty = false;
@@ -668,7 +646,6 @@ final class SessionBackend {
                        ) {
                                $this->logger->debug( "SessionBackend $this->id: Taking over PHP session" );
                                session_id( (string)$this->id );
-                               \MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
                                \MediaWiki\quietCall( 'session_start' );
                        }
                }
index 4b38a5c..57d5664 100644 (file)
@@ -25,6 +25,7 @@ namespace MediaWiki\Session;
 
 use Psr\Log\LoggerInterface;
 use BagOStuff;
+use CachedBagOStuff;
 use Config;
 use FauxRequest;
 use Language;
@@ -54,11 +55,8 @@ final class SessionManager implements SessionManagerInterface {
        /** @var Config */
        private $config;
 
-       /** @var BagOStuff|null */
-       private $tempStore;
-
-       /** @var BagOStuff|null */
-       private $permStore;
+       /** @var CachedBagOStuff|null */
+       private $store;
 
        /** @var SessionProvider[] */
        private $sessionProviders = null;
@@ -162,18 +160,18 @@ final class SessionManager implements SessionManagerInterface {
                        $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
                }
 
-               $this->tempStore = new \HashBagOStuff;
                if ( isset( $options['store'] ) ) {
                        if ( !$options['store'] instanceof BagOStuff ) {
                                throw new \InvalidArgumentException(
                                        '$options[\'store\'] must be an instance of BagOStuff'
                                );
                        }
-                       $this->permStore = $options['store'];
+                       $store = $options['store'];
                } else {
-                       $this->permStore = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
-                       $this->permStore->setLogger( $this->logger );
+                       $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
+                       $store->setLogger( $this->logger );
                }
+               $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
 
                register_shutdown_function( array( $this, 'shutdown' ) );
        }
@@ -206,14 +204,7 @@ final class SessionManager implements SessionManagerInterface {
                // Test this here to provide a better log message for the common case
                // of "no such ID"
                $key = wfMemcKey( 'MWSession', $id );
-               $existing = $this->tempStore->get( $key );
-               if ( $existing === false ) {
-                       $existing = $this->permStore->get( $key );
-                       if ( $existing !== false ) {
-                               $this->tempStore->set( $key, $existing );
-                       }
-               }
-               if ( is_array( $existing ) ) {
+               if ( is_array( $this->store->get( $key ) ) ) {
                        $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => $id, 'idIsSafe' => true ) );
                        if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
                                $session = $this->getSessionFromInfo( $info, $request );
@@ -251,14 +242,7 @@ final class SessionManager implements SessionManagerInterface {
                        }
 
                        $key = wfMemcKey( 'MWSession', $id );
-                       $existing = $this->tempStore->get( $key );
-                       if ( $existing === false ) {
-                               $existing = $this->permStore->get( $key );
-                               if ( $existing !== false ) {
-                                       $this->tempStore->set( $key, $existing );
-                               }
-                       }
-                       if ( is_array( $existing ) ) {
+                       if ( is_array( $this->store->get( $key ) ) ) {
                                throw new \InvalidArgumentException( 'Session ID already exists' );
                        }
                }
@@ -678,13 +662,7 @@ final class SessionManager implements SessionManagerInterface {
         */
        private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
                $key = wfMemcKey( 'MWSession', $info->getId() );
-               $blob = $this->tempStore->get( $key );
-               if ( $blob === false ) {
-                       $blob = $this->permStore->get( $key );
-                       if ( $blob !== false ) {
-                               $this->tempStore->set( $key, $blob );
-                       }
-               }
+               $blob = $this->store->get( $key );
 
                $newParams = array();
 
@@ -692,8 +670,7 @@ final class SessionManager implements SessionManagerInterface {
                        // Sanity check: blob must be an array, if it's saved at all
                        if ( !is_array( $blob ) ) {
                                $this->logger->warning( "Session $info: Bad data" );
-                               $this->tempStore->delete( $key );
-                               $this->permStore->delete( $key );
+                               $this->store->delete( $key );
                                return false;
                        }
 
@@ -702,8 +679,7 @@ final class SessionManager implements SessionManagerInterface {
                                !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
                        ) {
                                $this->logger->warning( "Session $info: Bad data structure" );
-                               $this->tempStore->delete( $key );
-                               $this->permStore->delete( $key );
+                               $this->store->delete( $key );
                                return false;
                        }
 
@@ -718,8 +694,7 @@ final class SessionManager implements SessionManagerInterface {
                                !array_key_exists( 'provider', $metadata )
                        ) {
                                $this->logger->warning( "Session $info: Bad metadata" );
-                               $this->tempStore->delete( $key );
-                               $this->permStore->delete( $key );
+                               $this->store->delete( $key );
                                return false;
                        }
 
@@ -729,8 +704,7 @@ final class SessionManager implements SessionManagerInterface {
                                $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
                                if ( !$provider ) {
                                        $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] );
-                                       $this->tempStore->delete( $key );
-                                       $this->permStore->delete( $key );
+                                       $this->store->delete( $key );
                                        return false;
                                }
                        } elseif ( $metadata['provider'] !== (string)$provider ) {
@@ -921,8 +895,7 @@ final class SessionManager implements SessionManagerInterface {
                        $backend = new SessionBackend(
                                $this->allSessionIds[$id],
                                $info,
-                               $this->tempStore,
-                               $this->permStore,
+                               $this->store,
                                $this->logger,
                                $this->config->get( 'ObjectCacheSessionExpiry' )
                        );
@@ -999,9 +972,7 @@ final class SessionManager implements SessionManagerInterface {
                do {
                        $id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
                        $key = wfMemcKey( 'MWSession', $id );
-               } while ( isset( $this->allSessionIds[$id] ) ||
-                       is_array( $this->tempStore->get( $key ) ) || is_array( $this->permStore->get( $key ) )
-               );
+               } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
                return $id;
        }
 
@@ -1011,7 +982,7 @@ final class SessionManager implements SessionManagerInterface {
         * @param PHPSessionHandler $handler
         */
        public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
-               $handler->setManager( $this, $this->permStore, $this->logger );
+               $handler->setManager( $this, $this->store, $this->logger );
        }
 
        /**
index 0417146..6158df2 100644 (file)
@@ -328,6 +328,29 @@ class SpecialPage {
                return array();
        }
 
+       /**
+        * Perform a regular substring search for prefixSearchSubpages
+        * @param string $search Prefix to search for
+        * @param int $limit Maximum number of results to return (usually 10)
+        * @param int $offset Number of results to skip (usually 0)
+        * @return string[] Matching subpages
+        */
+       protected function prefixSearchString( $search, $limit, $offset ) {
+               $title = Title::newFromText( $search );
+               if ( !$title || !$title->canExist() ) {
+                       // No prefix suggestion in special and media namespace
+                       return array();
+               }
+
+               $search = SearchEngine::create();
+               $search->setLimitOffset( $limit, $offset );
+               $search->setNamespaces( array() );
+               $result = $search->defaultPrefixSearch( $search );
+               return array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $result );
+       }
+
        /**
         * Helper function for implementations of prefixSearchSubpages() that
         * filter the values in memory (as opposed to making a query).
index 9e75522..0bf93be 100644 (file)
@@ -365,15 +365,7 @@ class SpecialAllPages extends IncludableSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index a9a7f97..1f32e3f 100644 (file)
@@ -234,15 +234,7 @@ class SpecialChangeContentModel extends FormSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 323903e..9970dfa 100644 (file)
@@ -246,9 +246,11 @@ class FileDuplicateSearchPage extends QueryPage {
                        // No prefix suggestion outside of file namespace
                        return array();
                }
+               $search = SearchEngine::create();
+               $search->setLimitOffset( $limit, $offset );
                // Autocomplete subpage the same as a normal search, but just for files
-               $prefixSearcher = new TitlePrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array( NS_FILE ), $offset );
+               $search->setNamespaces( array( NS_FILE ) );
+               $result = $search->defaultPrefixSearch( $search );
 
                return array_map( function ( Title $t ) {
                        // Remove namespace in search suggestion
index a7e5e02..339c1d9 100644 (file)
@@ -820,15 +820,7 @@ class MovePageForm extends UnlistedSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 69a9d48..38093be 100644 (file)
@@ -214,15 +214,7 @@ class SpecialPageLanguage extends FormSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index a6c0423..6401063 100644 (file)
@@ -303,15 +303,7 @@ class SpecialPrefixindex extends SpecialAllPages {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 8db8f24..dc210db 100644 (file)
@@ -273,14 +273,6 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 }
index f99a52d..078f032 100644 (file)
@@ -1709,15 +1709,7 @@ class SpecialUndelete extends SpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 47fd972..45ef9a2 100644 (file)
@@ -548,15 +548,7 @@ class SpecialWhatLinksHere extends IncludableSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index d866ed8..d4e7119 100644 (file)
@@ -2520,6 +2520,7 @@ Barack Obama <President> of the United States
 </p>
 !! end
 
+## PHP parser discards the "<pre " string
 !! test
 Handle broken pre-like tags (bug 64025)
 !! options
@@ -2530,13 +2531,8 @@ parsoid=wt2html
 <table><pre </table>
 !! html/php
 <pre>x</pre>
-<table>&lt;pre </table>
+<table><pre></pre></table>
 
-!! html/php+tidy
-<pre>
-x
-</pre>
-<p>&lt;pre</p>
 !! html/parsoid
 <pre about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"a":{"&lt;pre":null},"sa":{"&lt;pre":""},"stx":"html","pi":[[{"k":"1","spc":["","","",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;pre &lt;pre>x&lt;/pre>"}},"i":0}}]}'>x</pre>
 
@@ -10969,8 +10965,6 @@ Un-closed <includeonly>
 !! wikitext
 <includeonly>
 !! html
-<p>&lt;includeonly&gt;
-</p>
 !! end
 
 ## We used to, but no longer wt2wt this test since the default serializer
index b0df616..b28de57 100644 (file)
@@ -55,13 +55,19 @@ class AvroFormatterTest extends MediaWikiTestCase {
        }
 
        public function testDoesSomethingWhenSchemaAvailable() {
-               $formatter = new AvroFormatter( array( 'string' => array( 'type' => 'string' ) ) );
+               $formatter = new AvroFormatter( array(
+                       'string' => array(
+                               'schema' => array( 'type' => 'string' ),
+                               'revision' => 1010101,
+                       )
+               ) );
                $res = $formatter->format( array(
                        'channel' => 'string',
                        'context' => 'better to be',
                ) );
                $this->assertNotNull( $res );
-               // basically just tell us if avro changes its string encoding
-               $this->assertEquals( base64_decode( 'GGJldHRlciB0byBiZQ==' ), $res );
+               // basically just tell us if avro changes its string encoding, or if
+               // we completely fail to generate a log message.
+               $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) );
        }
 }
diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
new file mode 100644 (file)
index 0000000..3b19c9a
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @group BagOStuff
+ */
+class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
+
+       public function testGetFromBackend() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               $backend->set( 'foo', 'bar' );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+
+               $backend->set( 'foo', 'baz' );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' );
+       }
+
+       public function testSetAndDelete() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               for ( $i = 0; $i < 10; $i++ ) {
+                       $cache->set( "key$i", 1 );
+                       $this->assertEquals( 1, $cache->get( "key$i" ) );
+                       $this->assertEquals( 1, $backend->get( "key$i" ) );
+                       $cache->delete( "key$i" );
+                       $this->assertEquals( false, $cache->get( "key$i" ) );
+                       $this->assertEquals( false, $backend->get( "key$i" ) );
+               }
+       }
+
+       public function testWriteCacheOnly() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               $cache->set( 'foo', 'bar', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+               $this->assertFalse( $backend->get( 'foo' ) );
+
+               $cache->set( 'foo', 'old' );
+               $this->assertEquals( 'old', $cache->get( 'foo' ) );
+               $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+               $cache->set( 'foo', 'new', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'new', $cache->get( 'foo' ) );
+               $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+               $cache->delete( 'foo', CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend
+       }
+}
index b940230..1ebba1a 100644 (file)
@@ -48,7 +48,7 @@ class PreprocessorTest extends MediaWikiTestCase {
                        array( "<noinclude> Foo bar </noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore> Foo bar <ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore>\n</root>" ),
-                       array( "<gallery>foo bar", "<root>&lt;gallery&gt;foo bar</root>" ),
+                       array( "<gallery>foo bar", "<root><ext><name>gallery</name><attr></attr><inner>foo bar</inner></ext></root>" ),
                        array( "<{{foo}}>", "<root>&lt;<template><title>foo</title></template>&gt;</root>" ),
                        array( "<{{{foo}}}>", "<root>&lt;<tplarg><title>foo</title></tplarg>&gt;</root>" ),
                        array( "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner>&lt;/gallery</inner><close>&lt;/gallery&gt;</close></ext></root>" ),
diff --git a/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/tests/phpunit/includes/search/SearchEnginePrefixTest.php
new file mode 100644 (file)
index 0000000..2664fa6
--- /dev/null
@@ -0,0 +1,334 @@
+<?php
+/**
+ * @group Search
+ * @group Database
+ */
+class SearchEnginePrefixTest extends MediaWikiLangTestCase {
+
+       /**
+        * @var SearchEngine
+        */
+       private $search;
+
+       public function addDBData() {
+               if ( !$this->isWikitextNS( NS_MAIN ) ) {
+                       // tests are skipped if NS_MAIN is not wikitext
+                       return;
+               }
+
+               $this->insertPage( 'Sandbox' );
+               $this->insertPage( 'Bar' );
+               $this->insertPage( 'Example' );
+               $this->insertPage( 'Example Bar' );
+               $this->insertPage( 'Example Foo' );
+               $this->insertPage( 'Example Foo/Bar' );
+               $this->insertPage( 'Example/Baz' );
+               $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
+               $this->insertPage( 'Redirect Test' );
+               $this->insertPage( 'Redirect Test Worse Result' );
+               $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
+               $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
+               $this->insertPage( 'Redirect Test2' );
+               $this->insertPage( 'Redirect Test2 Worse Result' );
+
+               $this->insertPage( 'Talk:Sandbox' );
+               $this->insertPage( 'Talk:Example' );
+
+               $this->insertPage( 'User:Example' );
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               if ( !$this->isWikitextNS( NS_MAIN ) ) {
+                       $this->markTestSkipped( 'Main namespace does not support wikitext.' );
+               }
+
+               // Avoid special pages from extensions interferring with the tests
+               $this->setMwGlobals( 'wgSpecialPages', array() );
+               $this->search = SearchEngine::create();
+               $this->search->setNamespaces( array() );
+       }
+
+       protected function searchProvision( Array $results = null ) {
+               if ( $results === null ) {
+                       $this->setMwGlobals( 'wgHooks', array() );
+               } else {
+                       $this->setMwGlobals( 'wgHooks', array(
+                               'PrefixSearchBackend' => array(
+                                       function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
+                                               $srchres = $results;
+                                               return false;
+                                       }
+                               ),
+                       ) );
+               }
+       }
+
+       public static function provideSearch() {
+               return array(
+                       array( array(
+                               'Empty string',
+                               'query' => '',
+                               'results' => array(),
+                       ) ),
+                       array( array(
+                               'Main namespace with title prefix',
+                               'query' => 'Ex',
+                               'results' => array(
+                                       'Example',
+                                       'Example/Baz',
+                                       'Example Bar',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Example Foo',
+                               ),
+                       ) ),
+                       array( array(
+                               'Talk namespace prefix',
+                               'query' => 'Talk:',
+                               'results' => array(
+                                       'Talk:Example',
+                                       'Talk:Sandbox',
+                               ),
+                       ) ),
+                       array( array(
+                               'User namespace prefix',
+                               'query' => 'User:',
+                               'results' => array(
+                                       'User:Example',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special namespace prefix',
+                               'query' => 'Special:',
+                               'results' => array(
+                                       'Special:ActiveUsers',
+                                       'Special:AllMessages',
+                                       'Special:AllMyFiles',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Special:AllMyUploads',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special namespace with prefix',
+                               'query' => 'Special:Un',
+                               'results' => array(
+                                       'Special:Unblock',
+                                       'Special:UncategorizedCategories',
+                                       'Special:UncategorizedFiles',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Special:UncategorizedImages',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page name',
+                               'query' => 'Special:EditWatchlist',
+                               'results' => array(
+                                       'Special:EditWatchlist',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page subpages',
+                               'query' => 'Special:EditWatchlist/',
+                               'results' => array(
+                                       'Special:EditWatchlist/clear',
+                                       'Special:EditWatchlist/raw',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page subpages with prefix',
+                               'query' => 'Special:EditWatchlist/cl',
+                               'results' => array(
+                                       'Special:EditWatchlist/clear',
+                               ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideSearch
+        * @covers SearchEngine::defaultPrefixSearch
+        */
+       public function testSearch( Array $case ) {
+               $this->search->setLimitOffset( 3 );
+               $results = $this->search->defaultPrefixSearch( $case['query'] );
+               $results = array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $results );
+               $this->assertEquals(
+                       $case['results'],
+                       $results,
+                       $case[0]
+               );
+       }
+
+       /**
+        * @dataProvider provideSearch
+        * @covers SearchEngine::defaultPrefixSearch
+        */
+       public function testSearchWithOffset( Array $case ) {
+               $this->search->setLimitOffset( 3, 1 );
+               $results = $this->search->defaultPrefixSearch( $case['query'] );
+               $results = array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $results );
+
+               // We don't expect the first result when offsetting
+               array_shift( $case['results'] );
+               // And sometimes we expect a different last result
+               $expected = isset( $case['offsetresult'] ) ?
+                       array_merge( $case['results'], $case['offsetresult'] ) :
+                       $case['results'];
+
+               $this->assertEquals(
+                       $expected,
+                       $results,
+                       $case[0]
+               );
+       }
+
+       public static function provideSearchBackend() {
+               return array(
+                       array( array(
+                               'Simple case',
+                               'provision' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match not on top (bug 70958)',
+                               'provision' => array(
+                                       'Barcelona',
+                                       'Bar',
+                                       'Barbara',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match missing (bug 70958)',
+                               'provision' => array(
+                                       'Barcelona',
+                                       'Barbara',
+                                       'Bart',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match missing and not existing',
+                               'provision' => array(
+                                       'Exile',
+                                       'Exist',
+                                       'External',
+                               ),
+                               'query' => 'Ex',
+                               'results' => array(
+                                       'Exile',
+                                       'Exist',
+                                       'External',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match shouldn't override already found match if " .
+                                       "exact is redirect and found isn't",
+                               'provision' => array(
+                                       // Target of the exact match is low in the list
+                                       'Redirect Test Worse Result',
+                                       'Redirect Test',
+                               ),
+                               'query' => 'redirect test',
+                               'results' => array(
+                                       // Redirect target is pulled up and exact match isn't added
+                                       'Redirect Test',
+                                       'Redirect Test Worse Result',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match shouldn't override already found match if " .
+                                       "both exact match and found match are redirect",
+                               'provision' => array(
+                                       // Another redirect to the same target as the exact match
+                                       // is low in the list
+                                       'Redirect Test2 Worse Result',
+                                       'Redirect test2',
+                               ),
+                               'query' => 'redirect TEST2',
+                               'results' => array(
+                                       // Found redirect is pulled to the top and exact match isn't
+                                       // added
+                                       'Redirect test2',
+                                       'Redirect Test2 Worse Result',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match should override any already found matches that " .
+                                       "are redirects to it",
+                               'provision' => array(
+                                       // Another redirect to the same target as the exact match
+                                       // is low in the list
+                                       'Redirect Test Worse Result',
+                                       'Redirect test',
+                               ),
+                               'query' => 'Redirect Test',
+                               'results' => array(
+                                       // Found redirect is pulled to the top and exact match isn't
+                                       // added
+                                       'Redirect Test',
+                                       'Redirect Test Worse Result',
+                                       'Redirect test',
+                               ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideSearchBackend
+        * @covers PrefixSearch::searchBackend
+        */
+       public function testSearchBackend( Array $case ) {
+               $search = $stub = $this->getMockBuilder( 'SearchEngine' )
+                       ->setMethods( array( 'completionSearchBackend' ) )->getMock();
+
+               $return = SearchSuggestionSet::fromStrings( $case['provision'] );
+
+               $search->expects( $this->any() )
+                       ->method( 'completionSearchBackend' )
+                       ->will( $this->returnValue( $return ) );
+
+               $search->setLimitOffset( 3 );
+               $results = $search->completionSearch( $case['query'] );
+
+               $results = $results->map( function( SearchSuggestion $s ) {
+                       return $s->getText();
+               } );
+
+               $this->assertEquals(
+                       $case['results'],
+                       $results,
+                       $case[0]
+               );
+       }
+}
diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php
new file mode 100644 (file)
index 0000000..60559fc
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * Test for filter utilities.
+ *
+ * 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
+ */
+
+class SearchSuggestionSetTest extends \PHPUnit_Framework_TestCase {
+       /**
+        * Test that adding a new suggestion at the end
+        * will keep proper score ordering
+        */
+       public function testAppend() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               $this->assertEquals( 0, $set->getSize() );
+               $set->append( new SearchSuggestion( 3 ) );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+
+               $suggestion = new SearchSuggestion( 4 );
+               $set->append( $suggestion );
+               $this->assertEquals( 2, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+               $this->assertEquals( 2, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 2 );
+               $set->append( $suggestion );
+               $this->assertEquals( 1, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+               $this->assertEquals( 1, $suggestion->getScore() );
+
+               $scores = $set->map( function( $s ) {
+                       return $s->getScore();
+               } );
+               $sorted = $scores;
+               asort( $sorted );
+               $this->assertEquals( $sorted, $scores );
+       }
+
+       /**
+        * Test that adding a new best suggestion will keep proper score
+        * ordering
+        */
+       public function testInsertBest() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               $this->assertEquals( 0, $set->getSize() );
+               $set->prepend( new SearchSuggestion( 3 ) );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+
+               $suggestion = new SearchSuggestion( 4 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 4, $set->getBestScore() );
+               $this->assertEquals( 4, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 0 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 5, $set->getBestScore() );
+               $this->assertEquals( 5, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 2 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 6, $set->getBestScore() );
+               $this->assertEquals( 6, $suggestion->getScore() );
+
+               $scores = $set->map( function( $s ) {
+                       return $s->getScore();
+               } );
+               $sorted = $scores;
+               asort( $sorted );
+               $this->assertEquals( $sorted, $scores );
+       }
+
+       public function testShrink() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               for ( $i = 0; $i < 100; $i++ ) {
+                       $set->append( new SearchSuggestion( 0 ) );
+               }
+               $set->shrink( 10 );
+               $this->assertEquals( 10, $set->getSize() );
+
+               $set->shrink( 0 );
+               $this->assertEquals( 0, $set->getSize() );
+       }
+
+       // TODO: test for fromTitles
+}
index e5df458..9ba67df 100644 (file)
@@ -352,7 +352,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                $provider->setManager( SessionManager::singleton() );
 
                $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
-               $store = new \HashBagOStuff();
+               $store = new TestBagOStuff();
                $user = User::newFromName( 'UTSysop' );
                $anon = new User;
 
@@ -365,7 +365,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                                'idIsSafe' => true,
                        ) ),
                        $store,
-                       $store,
                        new \Psr\Log\NullLogger(),
                        10
                );
@@ -451,8 +450,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                                'persisted' => true,
                                'idIsSafe' => true,
                        ) ),
-                       new \EmptyBagOStuff(),
-                       new \EmptyBagOStuff(),
+                       new TestBagOStuff(),
                        new \Psr\Log\NullLogger(),
                        10
                );
@@ -544,7 +542,7 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                $provider->setManager( SessionManager::singleton() );
 
                $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
-               $store = new \HashBagOStuff();
+               $store = new TestBagOStuff();
                $user = User::newFromName( 'UTSysop' );
                $anon = new User;
 
@@ -557,7 +555,6 @@ class CookieSessionProviderTest extends MediaWikiTestCase {
                                'idIsSafe' => true,
                        ) ),
                        $store,
-                       $store,
                        new \Psr\Log\NullLogger(),
                        10
                );
index 46f23f3..95f8e01 100644 (file)
@@ -204,8 +204,7 @@ class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
                                'userInfo' => UserInfo::newFromUser( $user, true ),
                                'idIsSafe' => true,
                        ) ),
-                       new \EmptyBagOStuff(),
-                       new \EmptyBagOStuff(),
+                       new TestBagOStuff(),
                        new \Psr\Log\NullLogger(),
                        10
                );
index 1c54a20..3044aa7 100644 (file)
@@ -78,7 +78,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase {
                ini_set( 'session.use_cookies', 1 );
                ini_set( 'session.use_trans_sid', 1 );
 
-               $store = new \HashBagOStuff();
+               $store = new TestBagOStuff();
                $logger = new \TestLogger();
                $manager = new SessionManager( array(
                        'store' => $store,
@@ -112,7 +112,7 @@ class PHPSessionHandlerTest extends MediaWikiTestCase {
                        'wgObjectCacheSessionExpiry' => 2,
                ) );
 
-               $store = new \HashBagOStuff();
+               $store = new TestBagOStuff();
                $logger = new \TestLogger( true, function ( $m ) {
                        return preg_match( '/^SessionBackend a{32} /', $m ) ? null : $m;
                } );
@@ -172,6 +172,14 @@ class PHPSessionHandlerTest extends MediaWikiTestCase {
                        $this->assertSame( $expect, $_SESSION );
                }
 
+               // Test expiry
+               session_write_close();
+               ini_set( 'session.gc_divisor', 1 );
+               ini_set( 'session.gc_probability', 1 );
+               sleep( 3 );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+
                // Re-fill the session, then test that session_destroy() works.
                $_SESSION['AuthenticationSessionTest'] = $rand;
                session_write_close();
index 85fa9bd..f08a07d 100644 (file)
@@ -59,7 +59,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                ) );
                $id = new SessionId( $info->getId() );
 
-               $backend = new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
                $priv = \TestingAccessWrapper::newFromObject( $backend );
                $priv->persist = false;
                $priv->requests = array( 100 => new \FauxRequest() );
@@ -87,7 +87,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                $id = new SessionId( $info->getId() );
                $logger = new \Psr\Log\NullLogger();
                try {
-                       new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
                        $this->fail( 'Expected exception not thrown' );
                } catch ( \InvalidArgumentException $ex ) {
                        $this->assertSame(
@@ -103,7 +103,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                ) );
                $id = new SessionId( $info->getId() );
                try {
-                       new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
                        $this->fail( 'Expected exception not thrown' );
                } catch ( \InvalidArgumentException $ex ) {
                        $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() );
@@ -118,7 +118,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                ) );
                $id = new SessionId( '!' . $info->getId() );
                try {
-                       new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
                        $this->fail( 'Expected exception not thrown' );
                } catch ( \InvalidArgumentException $ex ) {
                        $this->assertSame(
@@ -135,7 +135,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                        'idIsSafe' => true,
                ) );
                $id = new SessionId( $info->getId() );
-               $backend = new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
                $this->assertSame( self::SESSIONID, $backend->getId() );
                $this->assertSame( $id, $backend->getSessionId() );
                $this->assertSame( $this->provider, $backend->getProvider() );
@@ -157,7 +157,7 @@ class SessionBackendTest extends MediaWikiTestCase {
                        'idIsSafe' => true,
                ) );
                $id = new SessionId( $info->getId() );
-               $backend = new SessionBackend( $id, $info, $this->store, $this->store, $logger, 10 );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
                $this->assertSame( self::SESSIONID, $backend->getId() );
                $this->assertSame( $id, $backend->getSessionId() );
                $this->assertSame( $this->provider, $backend->getProvider() );
@@ -468,6 +468,8 @@ class SessionBackendTest extends MediaWikiTestCase {
                $this->assertInternalType( 'array', $metadata );
                $this->assertArrayHasKey( '???', $metadata );
                $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertFalse( $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it didn\'t save to backend' );
 
                // Persistent, not dirty
                $this->provider = $neverProvider;
@@ -516,6 +518,8 @@ class SessionBackendTest extends MediaWikiTestCase {
                $this->assertInternalType( 'array', $metadata );
                $this->assertArrayHasKey( '???', $metadata );
                $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
 
                $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
                $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
@@ -538,6 +542,8 @@ class SessionBackendTest extends MediaWikiTestCase {
                $this->assertInternalType( 'array', $metadata );
                $this->assertArrayHasKey( '???', $metadata );
                $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
 
                $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
                $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
@@ -559,6 +565,8 @@ class SessionBackendTest extends MediaWikiTestCase {
                $this->assertInternalType( 'array', $metadata );
                $this->assertArrayHasKey( '???', $metadata );
                $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
 
                // Not marked dirty, but dirty data
                $this->provider = $neverProvider;
@@ -581,6 +589,8 @@ class SessionBackendTest extends MediaWikiTestCase {
                $this->assertInternalType( 'array', $metadata );
                $this->assertArrayHasKey( '???', $metadata );
                $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
 
                // Bad hook
                $this->provider = null;
index 4fde341..d8d5b4b 100644 (file)
@@ -103,7 +103,7 @@ class SessionManagerTest extends MediaWikiTestCase {
                $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
                $this->assertSame( $this->config, $manager->config );
                $this->assertSame( $this->logger, $manager->logger );
-               $this->assertSame( $this->store, $manager->permStore );
+               $this->assertSame( $this->store, $manager->store );
 
                $manager = \TestingAccessWrapper::newFromObject( new SessionManager() );
                $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config );
@@ -111,7 +111,7 @@ class SessionManagerTest extends MediaWikiTestCase {
                $manager = \TestingAccessWrapper::newFromObject( new SessionManager( array(
                        'config' => $this->config,
                ) ) );
-               $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->permStore );
+               $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
 
                foreach ( array(
                        'config' => '$options[\'config\'] must be an instance of Config',
@@ -300,10 +300,6 @@ class SessionManagerTest extends MediaWikiTestCase {
 
        public function testGetSessionById() {
                $manager = $this->getManager();
-
-               // Disable the in-process cache so our $this->store->setSession() takes effect.
-               \TestingAccessWrapper::newFromObject( $manager )->tempStore = new \EmptyBagOStuff;
-
                try {
                        $manager->getSessionById( 'bad' );
                        $this->fail( 'Expected exception not thrown' );
@@ -766,7 +762,7 @@ class SessionManagerTest extends MediaWikiTestCase {
 
                $that = $this;
 
-               \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff();
+               \ObjectCache::$instances[__METHOD__] = new TestBagOStuff();
                $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) );
 
                $this->stashMwGlobals( array( 'wgGroupPermissions' ) );
@@ -1086,9 +1082,6 @@ class SessionManagerTest extends MediaWikiTestCase {
                $manager->setLogger( $logger );
                $request = new \FauxRequest();
 
-               // Disable the in-process cache so our $this->store->setSession() takes effect.
-               \TestingAccessWrapper::newFromObject( $manager )->tempStore = new \EmptyBagOStuff;
-
                // TestingAccessWrapper can't handle methods with reference arguments, sigh.
                $rClass = new \ReflectionClass( $manager );
                $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
index e674e7b..8d1b544 100644 (file)
@@ -5,7 +5,11 @@ namespace MediaWiki\Session;
 /**
  * BagOStuff with utility functions for MediaWiki\\Session\\* testing
  */
-class TestBagOStuff extends \HashBagOStuff {
+class TestBagOStuff extends \CachedBagOStuff {
+
+       public function __construct() {
+               parent::__construct( new \HashBagOStuff );
+       }
 
        /**
         * @param string $id Session ID
@@ -68,6 +72,14 @@ class TestBagOStuff extends \HashBagOStuff {
                return $this->get( wfMemcKey( 'MWSession', $id ) );
        }
 
+       /**
+        * @param string $id Session ID
+        * @return mixed
+        */
+       public function getSessionFromBackend( $id ) {
+               return $this->backend->get( wfMemcKey( 'MWSession', $id ) );
+       }
+
        /**
         * @param string $id Session ID
         */