diff options
author | Pierre Schmitz <pierre@archlinux.de> | 2014-12-27 15:41:37 +0100 |
---|---|---|
committer | Pierre Schmitz <pierre@archlinux.de> | 2014-12-31 11:43:28 +0100 |
commit | c1f9b1f7b1b77776192048005dcc66dcf3df2bfb (patch) | |
tree | 2b38796e738dd74cb42ecd9bfd151803108386bc /includes/page | |
parent | b88ab0086858470dd1f644e64cb4e4f62bb2be9b (diff) |
Update to MediaWiki 1.24.1
Diffstat (limited to 'includes/page')
-rw-r--r-- | includes/page/Article.php | 2150 | ||||
-rw-r--r-- | includes/page/CategoryPage.php | 118 | ||||
-rw-r--r-- | includes/page/ImagePage.php | 1615 | ||||
-rw-r--r-- | includes/page/WikiCategoryPage.php | 50 | ||||
-rw-r--r-- | includes/page/WikiFilePage.php | 230 | ||||
-rw-r--r-- | includes/page/WikiPage.php | 3554 |
6 files changed, 7717 insertions, 0 deletions
diff --git a/includes/page/Article.php b/includes/page/Article.php new file mode 100644 index 00000000..4e753817 --- /dev/null +++ b/includes/page/Article.php @@ -0,0 +1,2150 @@ +<?php +/** + * User interface for page actions. + * + * 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 for viewing MediaWiki article and history. + * + * This maintains WikiPage functions for backwards compatibility. + * + * @todo Move and rewrite code to an Action class + * + * See design.txt for an overview. + * Note: edit user interface and cache support functions have been + * moved to separate EditPage and HTMLFileCache classes. + * + * @internal documentation reviewed 15 Mar 2010 + */ +class Article implements Page { + /** @var IContextSource The context this Article is executed in */ + protected $mContext; + + /** @var WikiPage The WikiPage object of this instance */ + protected $mPage; + + /** @var ParserOptions ParserOptions object for $wgUser articles */ + public $mParserOptions; + + /** + * @var string Text of the revision we are working on + * @todo BC cruft + */ + public $mContent; + + /** + * @var Content Content of the revision we are working on + * @since 1.21 + */ + public $mContentObject; + + /** @var bool Is the content ($mContent) already loaded? */ + public $mContentLoaded = false; + + /** @var int|null The oldid of the article that is to be shown, 0 for the current revision */ + public $mOldId; + + /** @var Title Title from which we were redirected here */ + public $mRedirectedFrom = null; + + /** @var string|bool URL to redirect to or false if none */ + public $mRedirectUrl = false; + + /** @var int Revision ID of revision we are working on */ + public $mRevIdFetched = 0; + + /** @var Revision Revision we are working on */ + public $mRevision = null; + + /** @var ParserOutput */ + public $mParserOutput; + + /** + * Constructor and clear the article + * @param Title $title Reference to a Title object. + * @param int $oldId Revision ID, null to fetch from request, zero for current + */ + public function __construct( Title $title, $oldId = null ) { + $this->mOldId = $oldId; + $this->mPage = $this->newPage( $title ); + } + + /** + * @param Title $title + * @return WikiPage + */ + protected function newPage( Title $title ) { + return new WikiPage( $title ); + } + + /** + * Constructor from a page id + * @param int $id Article ID to load + * @return Article|null + */ + public static function newFromID( $id ) { + $t = Title::newFromID( $id ); + # @todo FIXME: Doesn't inherit right + return $t == null ? null : new self( $t ); + # return $t == null ? null : new static( $t ); // PHP 5.3 + } + + /** + * Create an Article object of the appropriate class for the given page. + * + * @param Title $title + * @param IContextSource $context + * @return Article + */ + public static function newFromTitle( $title, IContextSource $context ) { + if ( NS_MEDIA == $title->getNamespace() ) { + // FIXME: where should this go? + $title = Title::makeTitle( NS_FILE, $title->getDBkey() ); + } + + $page = null; + wfRunHooks( 'ArticleFromTitle', array( &$title, &$page, $context ) ); + if ( !$page ) { + switch ( $title->getNamespace() ) { + case NS_FILE: + $page = new ImagePage( $title ); + break; + case NS_CATEGORY: + $page = new CategoryPage( $title ); + break; + default: + $page = new Article( $title ); + } + } + $page->setContext( $context ); + + return $page; + } + + /** + * Create an Article object of the appropriate class for the given page. + * + * @param WikiPage $page + * @param IContextSource $context + * @return Article + */ + public static function newFromWikiPage( WikiPage $page, IContextSource $context ) { + $article = self::newFromTitle( $page->getTitle(), $context ); + $article->mPage = $page; // override to keep process cached vars + return $article; + } + + /** + * Tell the page view functions that this view was redirected + * from another page on the wiki. + * @param Title $from + */ + public function setRedirectedFrom( Title $from ) { + $this->mRedirectedFrom = $from; + } + + /** + * Get the title object of the article + * + * @return Title Title object of this page + */ + public function getTitle() { + return $this->mPage->getTitle(); + } + + /** + * Get the WikiPage object of this instance + * + * @since 1.19 + * @return WikiPage + */ + public function getPage() { + return $this->mPage; + } + + /** + * Clear the object + */ + public function clear() { + $this->mContentLoaded = false; + + $this->mRedirectedFrom = null; # Title object if set + $this->mRevIdFetched = 0; + $this->mRedirectUrl = false; + + $this->mPage->clear(); + } + + /** + * Note that getContent/loadContent do not follow redirects anymore. + * If you need to fetch redirectable content easily, try + * the shortcut in WikiPage::getRedirectTarget() + * + * This function has side effects! Do not use this function if you + * only want the real revision text if any. + * + * @deprecated since 1.21; use WikiPage::getContent() instead + * + * @return string Return the text of this revision + */ + public function getContent() { + ContentHandler::deprecated( __METHOD__, '1.21' ); + $content = $this->getContentObject(); + return ContentHandler::getContentText( $content ); + } + + /** + * Returns a Content object representing the pages effective display content, + * not necessarily the revision's content! + * + * Note that getContent/loadContent do not follow redirects anymore. + * If you need to fetch redirectable content easily, try + * the shortcut in WikiPage::getRedirectTarget() + * + * This function has side effects! Do not use this function if you + * only want the real revision text if any. + * + * @return Content Return the content of this revision + * + * @since 1.21 + */ + protected function getContentObject() { + wfProfileIn( __METHOD__ ); + + if ( $this->mPage->getID() === 0 ) { + # If this is a MediaWiki:x message, then load the messages + # and return the message value for x. + if ( $this->getTitle()->getNamespace() == NS_MEDIAWIKI ) { + $text = $this->getTitle()->getDefaultMessageText(); + if ( $text === false ) { + $text = ''; + } + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + } else { + $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon'; + $content = new MessageContent( $message, null, 'parsemag' ); + } + } else { + $this->fetchContentObject(); + $content = $this->mContentObject; + } + + wfProfileOut( __METHOD__ ); + return $content; + } + + /** + * @return int The oldid of the article that is to be shown, 0 for the current revision + */ + public function getOldID() { + if ( is_null( $this->mOldId ) ) { + $this->mOldId = $this->getOldIDFromRequest(); + } + + return $this->mOldId; + } + + /** + * Sets $this->mRedirectUrl to a correct URL if the query parameters are incorrect + * + * @return int The old id for the request + */ + public function getOldIDFromRequest() { + $this->mRedirectUrl = false; + + $request = $this->getContext()->getRequest(); + $oldid = $request->getIntOrNull( 'oldid' ); + + if ( $oldid === null ) { + return 0; + } + + if ( $oldid !== 0 ) { + # Load the given revision and check whether the page is another one. + # In that case, update this instance to reflect the change. + if ( $oldid === $this->mPage->getLatest() ) { + $this->mRevision = $this->mPage->getRevision(); + } else { + $this->mRevision = Revision::newFromId( $oldid ); + if ( $this->mRevision !== null ) { + // Revision title doesn't match the page title given? + if ( $this->mPage->getID() != $this->mRevision->getPage() ) { + $function = array( get_class( $this->mPage ), 'newFromID' ); + $this->mPage = call_user_func( $function, $this->mRevision->getPage() ); + } + } + } + } + + if ( $request->getVal( 'direction' ) == 'next' ) { + $nextid = $this->getTitle()->getNextRevisionID( $oldid ); + if ( $nextid ) { + $oldid = $nextid; + $this->mRevision = null; + } else { + $this->mRedirectUrl = $this->getTitle()->getFullURL( 'redirect=no' ); + } + } elseif ( $request->getVal( 'direction' ) == 'prev' ) { + $previd = $this->getTitle()->getPreviousRevisionID( $oldid ); + if ( $previd ) { + $oldid = $previd; + $this->mRevision = null; + } + } + + return $oldid; + } + + /** + * Load the revision (including text) into this object + * + * @deprecated since 1.19; use fetchContent() + */ + function loadContent() { + wfDeprecated( __METHOD__, '1.19' ); + $this->fetchContent(); + } + + /** + * Get text of an article from database + * Does *NOT* follow redirects. + * + * @protected + * @note This is really internal functionality that should really NOT be + * used by other functions. For accessing article content, use the WikiPage + * class, especially WikiBase::getContent(). However, a lot of legacy code + * uses this method to retrieve page text from the database, so the function + * has to remain public for now. + * + * @return string|bool String containing article contents, or false if null + * @deprecated since 1.21, use WikiPage::getContent() instead + */ + function fetchContent() { #BC cruft! + ContentHandler::deprecated( __METHOD__, '1.21' ); + + if ( $this->mContentLoaded && $this->mContent ) { + return $this->mContent; + } + + wfProfileIn( __METHOD__ ); + + $content = $this->fetchContentObject(); + + if ( !$content ) { + wfProfileOut( __METHOD__ ); + return false; + } + + // @todo Get rid of mContent everywhere! + $this->mContent = ContentHandler::getContentText( $content ); + ContentHandler::runLegacyHooks( 'ArticleAfterFetchContent', array( &$this, &$this->mContent ) ); + + wfProfileOut( __METHOD__ ); + + return $this->mContent; + } + + /** + * Get text content object + * Does *NOT* follow redirects. + * @todo When is this null? + * + * @note Code that wants to retrieve page content from the database should + * use WikiPage::getContent(). + * + * @return Content|null|bool + * + * @since 1.21 + */ + protected function fetchContentObject() { + if ( $this->mContentLoaded ) { + return $this->mContentObject; + } + + wfProfileIn( __METHOD__ ); + + $this->mContentLoaded = true; + $this->mContent = null; + + $oldid = $this->getOldID(); + + # Pre-fill content with error message so that if something + # fails we'll have something telling us what we intended. + //XXX: this isn't page content but a UI message. horrible. + $this->mContentObject = new MessageContent( 'missing-revision', array( $oldid ), array() ); + + if ( $oldid ) { + # $this->mRevision might already be fetched by getOldIDFromRequest() + if ( !$this->mRevision ) { + $this->mRevision = Revision::newFromId( $oldid ); + if ( !$this->mRevision ) { + wfDebug( __METHOD__ . " failed to retrieve specified revision, id $oldid\n" ); + wfProfileOut( __METHOD__ ); + return false; + } + } + } else { + if ( !$this->mPage->getLatest() ) { + wfDebug( __METHOD__ . " failed to find page data for title " . + $this->getTitle()->getPrefixedText() . "\n" ); + wfProfileOut( __METHOD__ ); + return false; + } + + $this->mRevision = $this->mPage->getRevision(); + + if ( !$this->mRevision ) { + wfDebug( __METHOD__ . " failed to retrieve current page, rev_id " . + $this->mPage->getLatest() . "\n" ); + wfProfileOut( __METHOD__ ); + return false; + } + } + + // @todo FIXME: Horrible, horrible! This content-loading interface just plain sucks. + // We should instead work with the Revision object when we need it... + // Loads if user is allowed + $this->mContentObject = $this->mRevision->getContent( + Revision::FOR_THIS_USER, + $this->getContext()->getUser() + ); + $this->mRevIdFetched = $this->mRevision->getId(); + + wfRunHooks( 'ArticleAfterFetchContentObject', array( &$this, &$this->mContentObject ) ); + + wfProfileOut( __METHOD__ ); + + return $this->mContentObject; + } + + /** + * Returns true if the currently-referenced revision is the current edit + * to this page (and it exists). + * @return bool + */ + public function isCurrent() { + # If no oldid, this is the current version. + if ( $this->getOldID() == 0 ) { + return true; + } + + return $this->mPage->exists() && $this->mRevision && $this->mRevision->isCurrent(); + } + + /** + * Get the fetched Revision object depending on request parameters or null + * on failure. + * + * @since 1.19 + * @return Revision|null + */ + public function getRevisionFetched() { + $this->fetchContentObject(); + + return $this->mRevision; + } + + /** + * Use this to fetch the rev ID used on page views + * + * @return int Revision ID of last article revision + */ + public function getRevIdFetched() { + if ( $this->mRevIdFetched ) { + return $this->mRevIdFetched; + } else { + return $this->mPage->getLatest(); + } + } + + /** + * This is the default action of the index.php entry point: just view the + * page of the given title. + */ + public function view() { + global $wgUseFileCache, $wgUseETag, $wgDebugToolbar, $wgMaxRedirects; + + wfProfileIn( __METHOD__ ); + + # Get variables from query string + # As side effect this will load the revision and update the title + # in a revision ID is passed in the request, so this should remain + # the first call of this method even if $oldid is used way below. + $oldid = $this->getOldID(); + + $user = $this->getContext()->getUser(); + # Another whitelist check in case getOldID() is altering the title + $permErrors = $this->getTitle()->getUserPermissionsErrors( 'read', $user ); + if ( count( $permErrors ) ) { + wfDebug( __METHOD__ . ": denied on secondary read check\n" ); + wfProfileOut( __METHOD__ ); + throw new PermissionsError( 'read', $permErrors ); + } + + $outputPage = $this->getContext()->getOutput(); + # getOldID() may as well want us to redirect somewhere else + if ( $this->mRedirectUrl ) { + $outputPage->redirect( $this->mRedirectUrl ); + wfDebug( __METHOD__ . ": redirecting due to oldid\n" ); + wfProfileOut( __METHOD__ ); + + return; + } + + # If we got diff in the query, we want to see a diff page instead of the article. + if ( $this->getContext()->getRequest()->getCheck( 'diff' ) ) { + wfDebug( __METHOD__ . ": showing diff page\n" ); + $this->showDiffPage(); + wfProfileOut( __METHOD__ ); + + return; + } + + # Set page title (may be overridden by DISPLAYTITLE) + $outputPage->setPageTitle( $this->getTitle()->getPrefixedText() ); + + $outputPage->setArticleFlag( true ); + # Allow frames by default + $outputPage->allowClickjacking(); + + $parserCache = ParserCache::singleton(); + + $parserOptions = $this->getParserOptions(); + # Render printable version, use printable version cache + if ( $outputPage->isPrintable() ) { + $parserOptions->setIsPrintable( true ); + $parserOptions->setEditSection( false ); + } elseif ( !$this->isCurrent() || !$this->getTitle()->quickUserCan( 'edit', $user ) ) { + $parserOptions->setEditSection( false ); + } + + # Try client and file cache + if ( !$wgDebugToolbar && $oldid === 0 && $this->mPage->checkTouched() ) { + if ( $wgUseETag ) { + $outputPage->setETag( $parserCache->getETag( $this, $parserOptions ) ); + } + + # Use the greatest of the page's timestamp or the timestamp of any + # redirect in the chain (bug 67849) + $timestamp = $this->mPage->getTouched(); + if ( isset( $this->mRedirectedFrom ) ) { + $timestamp = max( $timestamp, $this->mRedirectedFrom->getTouched() ); + + # If there can be more than one redirect in the chain, we have + # to go through the whole chain too in case an intermediate + # redirect was changed. + if ( $wgMaxRedirects > 1 ) { + $titles = Revision::newFromTitle( $this->mRedirectedFrom ) + ->getContent( Revision::FOR_THIS_USER, $user ) + ->getRedirectChain(); + $thisTitle = $this->getTitle(); + foreach ( $titles as $title ) { + if ( Title::compare( $title, $thisTitle ) === 0 ) { + break; + } + $timestamp = max( $timestamp, $title->getTouched() ); + } + } + } + + # Is it client cached? + if ( $outputPage->checkLastModified( $timestamp ) ) { + wfDebug( __METHOD__ . ": done 304\n" ); + wfProfileOut( __METHOD__ ); + + return; + # Try file cache + } elseif ( $wgUseFileCache && $this->tryFileCache() ) { + wfDebug( __METHOD__ . ": done file cache\n" ); + # tell wgOut that output is taken care of + $outputPage->disable(); + $this->mPage->doViewUpdates( $user, $oldid ); + wfProfileOut( __METHOD__ ); + + return; + } + } + + # Should the parser cache be used? + $useParserCache = $this->mPage->isParserCacheUsed( $parserOptions, $oldid ); + wfDebug( 'Article::view using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" ); + if ( $user->getStubThreshold() ) { + wfIncrStats( 'pcache_miss_stub' ); + } + + $this->showRedirectedFromHeader(); + $this->showNamespaceHeader(); + + # Iterate through the possible ways of constructing the output text. + # Keep going until $outputDone is set, or we run out of things to do. + $pass = 0; + $outputDone = false; + $this->mParserOutput = false; + + while ( !$outputDone && ++$pass ) { + switch ( $pass ) { + case 1: + wfRunHooks( 'ArticleViewHeader', array( &$this, &$outputDone, &$useParserCache ) ); + break; + case 2: + # Early abort if the page doesn't exist + if ( !$this->mPage->exists() ) { + wfDebug( __METHOD__ . ": showing missing article\n" ); + $this->showMissingArticle(); + $this->mPage->doViewUpdates( $user ); + wfProfileOut( __METHOD__ ); + return; + } + + # Try the parser cache + if ( $useParserCache ) { + $this->mParserOutput = $parserCache->get( $this, $parserOptions ); + + if ( $this->mParserOutput !== false ) { + if ( $oldid ) { + wfDebug( __METHOD__ . ": showing parser cache contents for current rev permalink\n" ); + $this->setOldSubtitle( $oldid ); + } else { + wfDebug( __METHOD__ . ": showing parser cache contents\n" ); + } + $outputPage->addParserOutput( $this->mParserOutput ); + # Ensure that UI elements requiring revision ID have + # the correct version information. + $outputPage->setRevisionId( $this->mPage->getLatest() ); + # Preload timestamp to avoid a DB hit + $cachedTimestamp = $this->mParserOutput->getTimestamp(); + if ( $cachedTimestamp !== null ) { + $outputPage->setRevisionTimestamp( $cachedTimestamp ); + $this->mPage->setTimestamp( $cachedTimestamp ); + } + $outputDone = true; + } + } + break; + case 3: + # This will set $this->mRevision if needed + $this->fetchContentObject(); + + # Are we looking at an old revision + if ( $oldid && $this->mRevision ) { + $this->setOldSubtitle( $oldid ); + + if ( !$this->showDeletedRevisionHeader() ) { + wfDebug( __METHOD__ . ": cannot view deleted revision\n" ); + wfProfileOut( __METHOD__ ); + return; + } + } + + # Ensure that UI elements requiring revision ID have + # the correct version information. + $outputPage->setRevisionId( $this->getRevIdFetched() ); + # Preload timestamp to avoid a DB hit + $outputPage->setRevisionTimestamp( $this->getTimestamp() ); + + # Pages containing custom CSS or JavaScript get special treatment + if ( $this->getTitle()->isCssOrJsPage() || $this->getTitle()->isCssJsSubpage() ) { + wfDebug( __METHOD__ . ": showing CSS/JS source\n" ); + $this->showCssOrJsPage(); + $outputDone = true; + } elseif ( !wfRunHooks( 'ArticleContentViewCustom', + array( $this->fetchContentObject(), $this->getTitle(), $outputPage ) ) ) { + + # Allow extensions do their own custom view for certain pages + $outputDone = true; + } elseif ( !ContentHandler::runLegacyHooks( 'ArticleViewCustom', + array( $this->fetchContentObject(), $this->getTitle(), $outputPage ) ) ) { + + # Allow extensions do their own custom view for certain pages + $outputDone = true; + } + break; + case 4: + # Run the parse, protected by a pool counter + wfDebug( __METHOD__ . ": doing uncached parse\n" ); + + $content = $this->getContentObject(); + $poolArticleView = new PoolWorkArticleView( $this->getPage(), $parserOptions, + $this->getRevIdFetched(), $useParserCache, $content ); + + if ( !$poolArticleView->execute() ) { + $error = $poolArticleView->getError(); + if ( $error ) { + $outputPage->clearHTML(); // for release() errors + $outputPage->enableClientCache( false ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + + $errortext = $error->getWikiText( false, 'view-pool-error' ); + $outputPage->addWikiText( '<div class="errorbox">' . $errortext . '</div>' ); + } + # Connection or timeout error + wfProfileOut( __METHOD__ ); + return; + } + + $this->mParserOutput = $poolArticleView->getParserOutput(); + $outputPage->addParserOutput( $this->mParserOutput ); + if ( $content->getRedirectTarget() ) { + $outputPage->addSubtitle( + "<span id=\"redirectsub\">" . wfMessage( 'redirectpagesub' )->parse() . "</span>" + ); + } + + # Don't cache a dirty ParserOutput object + if ( $poolArticleView->getIsDirty() ) { + $outputPage->setSquidMaxage( 0 ); + $outputPage->addHTML( "<!-- parser cache is expired, " . + "sending anyway due to pool overload-->\n" ); + } + + $outputDone = true; + break; + # Should be unreachable, but just in case... + default: + break 2; + } + } + + # Get the ParserOutput actually *displayed* here. + # Note that $this->mParserOutput is the *current* version output. + $pOutput = ( $outputDone instanceof ParserOutput ) + ? $outputDone // object fetched by hook + : $this->mParserOutput; + + # Adjust title for main page & pages with displaytitle + if ( $pOutput ) { + $this->adjustDisplayTitle( $pOutput ); + } + + # For the main page, overwrite the <title> element with the con- + # tents of 'pagetitle-view-mainpage' instead of the default (if + # that's not empty). + # This message always exists because it is in the i18n files + if ( $this->getTitle()->isMainPage() ) { + $msg = wfMessage( 'pagetitle-view-mainpage' )->inContentLanguage(); + if ( !$msg->isDisabled() ) { + $outputPage->setHTMLTitle( $msg->title( $this->getTitle() )->text() ); + } + } + + # Check for any __NOINDEX__ tags on the page using $pOutput + $policy = $this->getRobotPolicy( 'view', $pOutput ); + $outputPage->setIndexPolicy( $policy['index'] ); + $outputPage->setFollowPolicy( $policy['follow'] ); + + $this->showViewFooter(); + $this->mPage->doViewUpdates( $user, $oldid ); + + $outputPage->addModules( 'mediawiki.action.view.postEdit' ); + + wfProfileOut( __METHOD__ ); + } + + /** + * Adjust title for pages with displaytitle, -{T|}- or language conversion + * @param ParserOutput $pOutput + */ + public function adjustDisplayTitle( ParserOutput $pOutput ) { + # Adjust the title if it was set by displaytitle, -{T|}- or language conversion + $titleText = $pOutput->getTitleText(); + if ( strval( $titleText ) !== '' ) { + $this->getContext()->getOutput()->setPageTitle( $titleText ); + } + } + + /** + * Show a diff page according to current request variables. For use within + * Article::view() only, other callers should use the DifferenceEngine class. + * + * @todo Make protected + */ + public function showDiffPage() { + $request = $this->getContext()->getRequest(); + $user = $this->getContext()->getUser(); + $diff = $request->getVal( 'diff' ); + $rcid = $request->getVal( 'rcid' ); + $diffOnly = $request->getBool( 'diffonly', $user->getOption( 'diffonly' ) ); + $purge = $request->getVal( 'action' ) == 'purge'; + $unhide = $request->getInt( 'unhide' ) == 1; + $oldid = $this->getOldID(); + + $rev = $this->getRevisionFetched(); + + if ( !$rev ) { + $this->getContext()->getOutput()->setPageTitle( wfMessage( 'errorpagetitle' ) ); + $this->getContext()->getOutput()->addWikiMsg( 'difference-missing-revision', $oldid, 1 ); + return; + } + + $contentHandler = $rev->getContentHandler(); + $de = $contentHandler->createDifferenceEngine( + $this->getContext(), + $oldid, + $diff, + $rcid, + $purge, + $unhide + ); + + // DifferenceEngine directly fetched the revision: + $this->mRevIdFetched = $de->mNewid; + $de->showDiffPage( $diffOnly ); + + // Run view updates for the newer revision being diffed (and shown + // below the diff if not $diffOnly). + list( $old, $new ) = $de->mapDiffPrevNext( $oldid, $diff ); + // New can be false, convert it to 0 - this conveniently means the latest revision + $this->mPage->doViewUpdates( $user, (int)$new ); + } + + /** + * Show a page view for a page formatted as CSS or JavaScript. To be called by + * Article::view() only. + * + * This exists mostly to serve the deprecated ShowRawCssJs hook (used to customize these views). + * It has been replaced by the ContentGetParserOutput hook, which lets you do the same but with + * more flexibility. + * + * @param bool $showCacheHint Whether to show a message telling the user + * to clear the browser cache (default: true). + */ + protected function showCssOrJsPage( $showCacheHint = true ) { + $outputPage = $this->getContext()->getOutput(); + + if ( $showCacheHint ) { + $dir = $this->getContext()->getLanguage()->getDir(); + $lang = $this->getContext()->getLanguage()->getCode(); + + $outputPage->wrapWikiMsg( + "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>", + 'clearyourcache' + ); + } + + $this->fetchContentObject(); + + if ( $this->mContentObject ) { + // Give hooks a chance to customise the output + if ( ContentHandler::runLegacyHooks( + 'ShowRawCssJs', + array( $this->mContentObject, $this->getTitle(), $outputPage ) ) + ) { + // If no legacy hooks ran, display the content of the parser output, including RL modules, + // but excluding metadata like categories and language links + $po = $this->mContentObject->getParserOutput( $this->getTitle() ); + $outputPage->addParserOutputContent( $po ); + } + } + } + + /** + * Get the robot policy to be used for the current view + * @param string $action The action= GET parameter + * @param ParserOutput|null $pOutput + * @return array The policy that should be set + * @todo actions other than 'view' + */ + public function getRobotPolicy( $action, $pOutput = null ) { + global $wgArticleRobotPolicies, $wgNamespaceRobotPolicies, $wgDefaultRobotPolicy; + + $ns = $this->getTitle()->getNamespace(); + + # Don't index user and user talk pages for blocked users (bug 11443) + if ( ( $ns == NS_USER || $ns == NS_USER_TALK ) && !$this->getTitle()->isSubpage() ) { + $specificTarget = null; + $vagueTarget = null; + $titleText = $this->getTitle()->getText(); + if ( IP::isValid( $titleText ) ) { + $vagueTarget = $titleText; + } else { + $specificTarget = $titleText; + } + if ( Block::newFromTarget( $specificTarget, $vagueTarget ) instanceof Block ) { + return array( + 'index' => 'noindex', + 'follow' => 'nofollow' + ); + } + } + + if ( $this->mPage->getID() === 0 || $this->getOldID() ) { + # Non-articles (special pages etc), and old revisions + return array( + 'index' => 'noindex', + 'follow' => 'nofollow' + ); + } elseif ( $this->getContext()->getOutput()->isPrintable() ) { + # Discourage indexing of printable versions, but encourage following + return array( + 'index' => 'noindex', + 'follow' => 'follow' + ); + } elseif ( $this->getContext()->getRequest()->getInt( 'curid' ) ) { + # For ?curid=x urls, disallow indexing + return array( + 'index' => 'noindex', + 'follow' => 'follow' + ); + } + + # Otherwise, construct the policy based on the various config variables. + $policy = self::formatRobotPolicy( $wgDefaultRobotPolicy ); + + if ( isset( $wgNamespaceRobotPolicies[$ns] ) ) { + # Honour customised robot policies for this namespace + $policy = array_merge( + $policy, + self::formatRobotPolicy( $wgNamespaceRobotPolicies[$ns] ) + ); + } + if ( $this->getTitle()->canUseNoindex() && is_object( $pOutput ) && $pOutput->getIndexPolicy() ) { + # __INDEX__ and __NOINDEX__ magic words, if allowed. Incorporates + # a final sanity check that we have really got the parser output. + $policy = array_merge( + $policy, + array( 'index' => $pOutput->getIndexPolicy() ) + ); + } + + if ( isset( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) ) { + # (bug 14900) site config can override user-defined __INDEX__ or __NOINDEX__ + $policy = array_merge( + $policy, + self::formatRobotPolicy( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) + ); + } + + return $policy; + } + + /** + * Converts a String robot policy into an associative array, to allow + * merging of several policies using array_merge(). + * @param array|string $policy Returns empty array on null/false/'', transparent + * to already-converted arrays, converts string. + * @return array 'index' => \<indexpolicy\>, 'follow' => \<followpolicy\> + */ + public static function formatRobotPolicy( $policy ) { + if ( is_array( $policy ) ) { + return $policy; + } elseif ( !$policy ) { + return array(); + } + + $policy = explode( ',', $policy ); + $policy = array_map( 'trim', $policy ); + + $arr = array(); + foreach ( $policy as $var ) { + if ( in_array( $var, array( 'index', 'noindex' ) ) ) { + $arr['index'] = $var; + } elseif ( in_array( $var, array( 'follow', 'nofollow' ) ) ) { + $arr['follow'] = $var; + } + } + + return $arr; + } + + /** + * If this request is a redirect view, send "redirected from" subtitle to + * the output. Returns true if the header was needed, false if this is not + * a redirect view. Handles both local and remote redirects. + * + * @return bool + */ + public function showRedirectedFromHeader() { + global $wgRedirectSources; + $outputPage = $this->getContext()->getOutput(); + + $request = $this->getContext()->getRequest(); + $rdfrom = $request->getVal( 'rdfrom' ); + + // Construct a URL for the current page view, but with the target title + $query = $request->getValues(); + unset( $query['rdfrom'] ); + unset( $query['title'] ); + if ( $this->getTitle()->isRedirect() ) { + // Prevent double redirects + $query['redirect'] = 'no'; + } + $redirectTargetUrl = $this->getTitle()->getLinkURL( $query ); + + if ( isset( $this->mRedirectedFrom ) ) { + // This is an internally redirected page view. + // We'll need a backlink to the source page for navigation. + if ( wfRunHooks( 'ArticleViewRedirect', array( &$this ) ) ) { + $redir = Linker::linkKnown( + $this->mRedirectedFrom, + null, + array(), + array( 'redirect' => 'no' ) + ); + + $outputPage->addSubtitle( wfMessage( 'redirectedfrom' )->rawParams( $redir ) ); + + // Add the script to update the displayed URL and + // set the fragment if one was specified in the redirect + $outputPage->addJsConfigVars( array( + 'wgInternalRedirectTargetUrl' => $redirectTargetUrl, + ) ); + $outputPage->addModules( 'mediawiki.action.view.redirect' ); + + // Add a <link rel="canonical"> tag + $outputPage->setCanonicalUrl( $this->getTitle()->getLocalURL() ); + + // Tell the output object that the user arrived at this article through a redirect + $outputPage->setRedirectedFrom( $this->mRedirectedFrom ); + + return true; + } + } elseif ( $rdfrom ) { + // This is an externally redirected view, from some other wiki. + // If it was reported from a trusted site, supply a backlink. + if ( $wgRedirectSources && preg_match( $wgRedirectSources, $rdfrom ) ) { + $redir = Linker::makeExternalLink( $rdfrom, $rdfrom ); + $outputPage->addSubtitle( wfMessage( 'redirectedfrom' )->rawParams( $redir ) ); + + // Add the script to update the displayed URL + $outputPage->addJsConfigVars( array( + 'wgInternalRedirectTargetUrl' => $redirectTargetUrl, + ) ); + $outputPage->addModules( 'mediawiki.action.view.redirect' ); + + return true; + } + } + + return false; + } + + /** + * Show a header specific to the namespace currently being viewed, like + * [[MediaWiki:Talkpagetext]]. For Article::view(). + */ + public function showNamespaceHeader() { + if ( $this->getTitle()->isTalkPage() ) { + if ( !wfMessage( 'talkpageheader' )->isDisabled() ) { + $this->getContext()->getOutput()->wrapWikiMsg( + "<div class=\"mw-talkpageheader\">\n$1\n</div>", + array( 'talkpageheader' ) + ); + } + } + } + + /** + * Show the footer section of an ordinary page view + */ + public function showViewFooter() { + # check if we're displaying a [[User talk:x.x.x.x]] anonymous talk page + if ( $this->getTitle()->getNamespace() == NS_USER_TALK + && IP::isValid( $this->getTitle()->getText() ) + ) { + $this->getContext()->getOutput()->addWikiMsg( 'anontalkpagetext' ); + } + + // Show a footer allowing the user to patrol the shown revision or page if possible + $patrolFooterShown = $this->showPatrolFooter(); + + wfRunHooks( 'ArticleViewFooter', array( $this, $patrolFooterShown ) ); + } + + /** + * If patrol is possible, output a patrol UI box. This is called from the + * footer section of ordinary page views. If patrol is not possible or not + * desired, does nothing. + * Side effect: When the patrol link is build, this method will call + * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax. + * + * @return bool + */ + public function showPatrolFooter() { + global $wgUseNPPatrol, $wgUseRCPatrol, $wgEnableAPI, $wgEnableWriteAPI; + + $outputPage = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + $cache = wfGetMainCache(); + $rc = false; + + if ( !$this->getTitle()->quickUserCan( 'patrol', $user ) + || !( $wgUseRCPatrol || $wgUseNPPatrol ) + ) { + // Patrolling is disabled or the user isn't allowed to + return false; + } + + wfProfileIn( __METHOD__ ); + + // New page patrol: Get the timestamp of the oldest revison which + // the revision table holds for the given page. Then we look + // whether it's within the RC lifespan and if it is, we try + // to get the recentchanges row belonging to that entry + // (with rc_new = 1). + + // Check for cached results + if ( $cache->get( wfMemcKey( 'NotPatrollablePage', $this->getTitle()->getArticleID() ) ) ) { + wfProfileOut( __METHOD__ ); + return false; + } + + if ( $this->mRevision + && !RecentChange::isInRCLifespan( $this->mRevision->getTimestamp(), 21600 ) + ) { + // The current revision is already older than what could be in the RC table + // 6h tolerance because the RC might not be cleaned out regularly + wfProfileOut( __METHOD__ ); + return false; + } + + $dbr = wfGetDB( DB_SLAVE ); + $oldestRevisionTimestamp = $dbr->selectField( + 'revision', + 'MIN( rev_timestamp )', + array( 'rev_page' => $this->getTitle()->getArticleID() ), + __METHOD__ + ); + + if ( $oldestRevisionTimestamp + && RecentChange::isInRCLifespan( $oldestRevisionTimestamp, 21600 ) + ) { + // 6h tolerance because the RC might not be cleaned out regularly + $rc = RecentChange::newFromConds( + array( + 'rc_new' => 1, + 'rc_timestamp' => $oldestRevisionTimestamp, + 'rc_namespace' => $this->getTitle()->getNamespace(), + 'rc_cur_id' => $this->getTitle()->getArticleID(), + 'rc_patrolled' => 0 + ), + __METHOD__, + array( 'USE INDEX' => 'new_name_timestamp' ) + ); + } + + if ( !$rc ) { + // No RC entry around + + // Cache the information we gathered above in case we can't patrol + // Don't cache in case we can patrol as this could change + $cache->set( wfMemcKey( 'NotPatrollablePage', $this->getTitle()->getArticleID() ), '1' ); + + wfProfileOut( __METHOD__ ); + return false; + } + + if ( $rc->getPerformer()->getName() == $user->getName() ) { + // Don't show a patrol link for own creations. If the user could + // patrol them, they already would be patrolled + wfProfileOut( __METHOD__ ); + return false; + } + + $rcid = $rc->getAttribute( 'rc_id' ); + + $token = $user->getEditToken( $rcid ); + + $outputPage->preventClickjacking(); + if ( $wgEnableAPI && $wgEnableWriteAPI && $user->isAllowed( 'writeapi' ) ) { + $outputPage->addModules( 'mediawiki.page.patrol.ajax' ); + } + + $link = Linker::linkKnown( + $this->getTitle(), + wfMessage( 'markaspatrolledtext' )->escaped(), + array(), + array( + 'action' => 'markpatrolled', + 'rcid' => $rcid, + 'token' => $token, + ) + ); + + $outputPage->addHTML( + "<div class='patrollink'>" . + wfMessage( 'markaspatrolledlink' )->rawParams( $link )->escaped() . + '</div>' + ); + + wfProfileOut( __METHOD__ ); + return true; + } + + /** + * Show the error text for a missing article. For articles in the MediaWiki + * namespace, show the default message text. To be called from Article::view(). + */ + public function showMissingArticle() { + global $wgSend404Code; + + $outputPage = $this->getContext()->getOutput(); + // Whether the page is a root user page of an existing user (but not a subpage) + $validUserPage = false; + + $title = $this->getTitle(); + + # Show info in user (talk) namespace. Does the user exist? Is he blocked? + if ( $title->getNamespace() == NS_USER + || $title->getNamespace() == NS_USER_TALK + ) { + $parts = explode( '/', $title->getText() ); + $rootPart = $parts[0]; + $user = User::newFromName( $rootPart, false /* allow IP users*/ ); + $ip = User::isIP( $rootPart ); + $block = Block::newFromTarget( $user, $user ); + + if ( !( $user && $user->isLoggedIn() ) && !$ip ) { # User does not exist + $outputPage->wrapWikiMsg( "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>", + array( 'userpage-userdoesnotexist-view', wfEscapeWikiText( $rootPart ) ) ); + } elseif ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { # Show log extract if the user is currently blocked + LogEventsList::showLogExtract( + $outputPage, + 'block', + MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(), + '', + array( + 'lim' => 1, + 'showIfEmpty' => false, + 'msgKey' => array( + 'blocked-notice-logextract', + $user->getName() # Support GENDER in notice + ) + ) + ); + $validUserPage = !$title->isSubpage(); + } else { + $validUserPage = !$title->isSubpage(); + } + } + + wfRunHooks( 'ShowMissingArticle', array( $this ) ); + + // Give extensions a chance to hide their (unrelated) log entries + $logTypes = array( 'delete', 'move' ); + $conds = array( "log_action != 'revision'" ); + wfRunHooks( 'Article::MissingArticleConditions', array( &$conds, $logTypes ) ); + + # Show delete and move logs + $member = $title->getNamespace() . ':' . $title->getDBkey(); + // @todo: move optimization to showLogExtract()? + if ( BloomCache::get( 'main' )->check( wfWikiId(), 'TitleHasLogs', $member ) ) { + LogEventsList::showLogExtract( $outputPage, $logTypes, $title, '', + array( 'lim' => 10, + 'conds' => $conds, + 'showIfEmpty' => false, + 'msgKey' => array( 'moveddeleted-notice' ) ) + ); + } + + if ( !$this->mPage->hasViewableContent() && $wgSend404Code && !$validUserPage ) { + // If there's no backing content, send a 404 Not Found + // for better machine handling of broken links. + $this->getContext()->getRequest()->response()->header( "HTTP/1.1 404 Not Found" ); + } + + // Also apply the robot policy for nonexisting pages (even if a 404 was used for sanity) + $policy = $this->getRobotPolicy( 'view' ); + $outputPage->setIndexPolicy( $policy['index'] ); + $outputPage->setFollowPolicy( $policy['follow'] ); + + $hookResult = wfRunHooks( 'BeforeDisplayNoArticleText', array( $this ) ); + + if ( !$hookResult ) { + return; + } + + # Show error message + $oldid = $this->getOldID(); + if ( $oldid ) { + $text = wfMessage( 'missing-revision', $oldid )->plain(); + } elseif ( $title->getNamespace() === NS_MEDIAWIKI ) { + // Use the default message text + $text = $title->getDefaultMessageText(); + } elseif ( $title->quickUserCan( 'create', $this->getContext()->getUser() ) + && $title->quickUserCan( 'edit', $this->getContext()->getUser() ) + ) { + $message = $this->getContext()->getUser()->isLoggedIn() ? 'noarticletext' : 'noarticletextanon'; + $text = wfMessage( $message )->plain(); + } else { + $text = wfMessage( 'noarticletext-nopermission' )->plain(); + } + $text = "<div class='noarticletext'>\n$text\n</div>"; + + $outputPage->addWikiText( $text ); + } + + /** + * If the revision requested for view is deleted, check permissions. + * Send either an error message or a warning header to the output. + * + * @return bool True if the view is allowed, false if not. + */ + public function showDeletedRevisionHeader() { + if ( !$this->mRevision->isDeleted( Revision::DELETED_TEXT ) ) { + // Not deleted + return true; + } + + $outputPage = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + // If the user is not allowed to see it... + if ( !$this->mRevision->userCan( Revision::DELETED_TEXT, $user ) ) { + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + 'rev-deleted-text-permission' ); + + return false; + // If the user needs to confirm that they want to see it... + } elseif ( $this->getContext()->getRequest()->getInt( 'unhide' ) != 1 ) { + # Give explanation and add a link to view the revision... + $oldid = intval( $this->getOldID() ); + $link = $this->getTitle()->getFullURL( "oldid={$oldid}&unhide=1" ); + $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-unhide' : 'rev-deleted-text-unhide'; + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + array( $msg, $link ) ); + + return false; + // We are allowed to see... + } else { + $msg = $this->mRevision->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-view' : 'rev-deleted-text-view'; + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", $msg ); + + return true; + } + } + + /** + * Generate the navigation links when browsing through an article revisions + * It shows the information as: + * Revision as of \<date\>; view current revision + * \<- Previous version | Next Version -\> + * + * @param int $oldid Revision ID of this article revision + */ + public function setOldSubtitle( $oldid = 0 ) { + if ( !wfRunHooks( 'DisplayOldSubtitle', array( &$this, &$oldid ) ) ) { + return; + } + + $unhide = $this->getContext()->getRequest()->getInt( 'unhide' ) == 1; + + # Cascade unhide param in links for easy deletion browsing + $extraParams = array(); + if ( $unhide ) { + $extraParams['unhide'] = 1; + } + + if ( $this->mRevision && $this->mRevision->getId() === $oldid ) { + $revision = $this->mRevision; + } else { + $revision = Revision::newFromId( $oldid ); + } + + $timestamp = $revision->getTimestamp(); + + $current = ( $oldid == $this->mPage->getLatest() ); + $language = $this->getContext()->getLanguage(); + $user = $this->getContext()->getUser(); + + $td = $language->userTimeAndDate( $timestamp, $user ); + $tddate = $language->userDate( $timestamp, $user ); + $tdtime = $language->userTime( $timestamp, $user ); + + # Show user links if allowed to see them. If hidden, then show them only if requested... + $userlinks = Linker::revUserTools( $revision, !$unhide ); + + $infomsg = $current && !wfMessage( 'revision-info-current' )->isDisabled() + ? 'revision-info-current' + : 'revision-info'; + + $outputPage = $this->getContext()->getOutput(); + $outputPage->addSubtitle( "<div id=\"mw-{$infomsg}\">" . wfMessage( $infomsg, + $td )->rawParams( $userlinks )->params( $revision->getID(), $tddate, + $tdtime, $revision->getUserText() )->rawParams( Linker::revComment( $revision, true, true ) )->parse() . "</div>" ); + + $lnk = $current + ? wfMessage( 'currentrevisionlink' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + wfMessage( 'currentrevisionlink' )->escaped(), + array(), + $extraParams + ); + $curdiff = $current + ? wfMessage( 'diff' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + wfMessage( 'diff' )->escaped(), + array(), + array( + 'diff' => 'cur', + 'oldid' => $oldid + ) + $extraParams + ); + $prev = $this->getTitle()->getPreviousRevisionID( $oldid ); + $prevlink = $prev + ? Linker::linkKnown( + $this->getTitle(), + wfMessage( 'previousrevision' )->escaped(), + array(), + array( + 'direction' => 'prev', + 'oldid' => $oldid + ) + $extraParams + ) + : wfMessage( 'previousrevision' )->escaped(); + $prevdiff = $prev + ? Linker::linkKnown( + $this->getTitle(), + wfMessage( 'diff' )->escaped(), + array(), + array( + 'diff' => 'prev', + 'oldid' => $oldid + ) + $extraParams + ) + : wfMessage( 'diff' )->escaped(); + $nextlink = $current + ? wfMessage( 'nextrevision' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + wfMessage( 'nextrevision' )->escaped(), + array(), + array( + 'direction' => 'next', + 'oldid' => $oldid + ) + $extraParams + ); + $nextdiff = $current + ? wfMessage( 'diff' )->escaped() + : Linker::linkKnown( + $this->getTitle(), + wfMessage( 'diff' )->escaped(), + array(), + array( + 'diff' => 'next', + 'oldid' => $oldid + ) + $extraParams + ); + + $cdel = Linker::getRevDeleteLink( $user, $revision, $this->getTitle() ); + if ( $cdel !== '' ) { + $cdel .= ' '; + } + + $outputPage->addSubtitle( "<div id=\"mw-revision-nav\">" . $cdel . + wfMessage( 'revision-nav' )->rawParams( + $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff + )->escaped() . "</div>" ); + } + + /** + * Return the HTML for the top of a redirect page + * + * Chances are you should just be using the ParserOutput from + * WikitextContent::getParserOutput instead of calling this for redirects. + * + * @param Title|array $target Destination(s) to redirect + * @param bool $appendSubtitle [optional] + * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence? + * @return string Containing HTML with redirect link + */ + public function viewRedirect( $target, $appendSubtitle = true, $forceKnown = false ) { + $lang = $this->getTitle()->getPageLanguage(); + $out = $this->getContext()->getOutput(); + if ( $appendSubtitle ) { + $out->addSubtitle( wfMessage( 'redirectpagesub' )->parse() ); + } + $out->addModuleStyles( 'mediawiki.action.view.redirectPage' ); + return static::getRedirectHeaderHtml( $lang, $target, $forceKnown ); + } + + /** + * Return the HTML for the top of a redirect page + * + * Chances are you should just be using the ParserOutput from + * WikitextContent::getParserOutput instead of calling this for redirects. + * + * @since 1.23 + * @param Language $lang + * @param Title|array $target Destination(s) to redirect + * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence? + * @return string Containing HTML with redirect link + */ + public static function getRedirectHeaderHtml( Language $lang, $target, $forceKnown = false ) { + if ( !is_array( $target ) ) { + $target = array( $target ); + } + + $html = '<ul class="redirectText">'; + /** @var Title $title */ + foreach ( $target as $title ) { + $html .= '<li>' . Linker::link( + $title, + htmlspecialchars( $title->getFullText() ), + array(), + // Automatically append redirect=no to each link, since most of them are + // redirect pages themselves. + array( 'redirect' => 'no' ), + ( $forceKnown ? array( 'known', 'noclasses' ) : array() ) + ) . '</li>'; + } + + $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->text(); + + return '<div class="redirectMsg">' . + '<p>' . $redirectToText . '</p>' . + $html . + '</div>'; + } + + /** + * Handle action=render + */ + public function render() { + $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' ); + $this->getContext()->getOutput()->setArticleBodyOnly( true ); + $this->getContext()->getOutput()->enableSectionEditLinks( false ); + $this->view(); + } + + /** + * action=protect handler + */ + public function protect() { + $form = new ProtectionForm( $this ); + $form->execute(); + } + + /** + * action=unprotect handler (alias) + */ + public function unprotect() { + $this->protect(); + } + + /** + * UI entry point for page deletion + */ + public function delete() { + # This code desperately needs to be totally rewritten + + $title = $this->getTitle(); + $user = $this->getContext()->getUser(); + + # Check permissions + $permissionErrors = $title->getUserPermissionsErrors( 'delete', $user ); + if ( count( $permissionErrors ) ) { + throw new PermissionsError( 'delete', $permissionErrors ); + } + + # Read-only check... + if ( wfReadOnly() ) { + throw new ReadOnlyError; + } + + # Better double-check that it hasn't been deleted yet! + $this->mPage->loadPageData( 'fromdbmaster' ); + if ( !$this->mPage->exists() ) { + $deleteLogPage = new LogPage( 'delete' ); + $outputPage = $this->getContext()->getOutput(); + $outputPage->setPageTitle( wfMessage( 'cannotdelete-title', $title->getPrefixedText() ) ); + $outputPage->wrapWikiMsg( "<div class=\"error mw-error-cannotdelete\">\n$1\n</div>", + array( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ) + ); + $outputPage->addHTML( + Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) + ); + LogEventsList::showLogExtract( + $outputPage, + 'delete', + $title + ); + + return; + } + + $request = $this->getContext()->getRequest(); + $deleteReasonList = $request->getText( 'wpDeleteReasonList', 'other' ); + $deleteReason = $request->getText( 'wpReason' ); + + if ( $deleteReasonList == 'other' ) { + $reason = $deleteReason; + } elseif ( $deleteReason != '' ) { + // Entry from drop down menu + additional comment + $colonseparator = wfMessage( 'colon-separator' )->inContentLanguage()->text(); + $reason = $deleteReasonList . $colonseparator . $deleteReason; + } else { + $reason = $deleteReasonList; + } + + if ( $request->wasPosted() && $user->matchEditToken( $request->getVal( 'wpEditToken' ), + array( 'delete', $this->getTitle()->getPrefixedText() ) ) + ) { + # Flag to hide all contents of the archived revisions + $suppress = $request->getVal( 'wpSuppress' ) && $user->isAllowed( 'suppressrevision' ); + + $this->doDelete( $reason, $suppress ); + + WatchAction::doWatchOrUnwatch( $request->getCheck( 'wpWatch' ), $title, $user ); + + return; + } + + // Generate deletion reason + $hasHistory = false; + if ( !$reason ) { + try { + $reason = $this->generateReason( $hasHistory ); + } catch ( MWException $e ) { + # if a page is horribly broken, we still want to be able to + # delete it. So be lenient about errors here. + wfDebug( "Error while building auto delete summary: $e" ); + $reason = ''; + } + } + + // If the page has a history, insert a warning + if ( $hasHistory ) { + $title = $this->getTitle(); + + // The following can use the real revision count as this is only being shown for users that can delete + // this page. + // This, as a side-effect, also makes sure that the following query isn't being run for pages with a + // larger history, unless the user has the 'bigdelete' right (and is about to delete this page). + $dbr = wfGetDB( DB_SLAVE ); + $revisions = $edits = (int)$dbr->selectField( + 'revision', + 'COUNT(rev_page)', + array( 'rev_page' => $title->getArticleID() ), + __METHOD__ + ); + + // @todo FIXME: i18n issue/patchwork message + $this->getContext()->getOutput()->addHTML( '<strong class="mw-delete-warning-revisions">' . + wfMessage( 'historywarning' )->numParams( $revisions )->parse() . + wfMessage( 'word-separator' )->plain() . Linker::linkKnown( $title, + wfMessage( 'history' )->escaped(), + array( 'rel' => 'archives' ), + array( 'action' => 'history' ) ) . + '</strong>' + ); + + if ( $title->isBigDeletion() ) { + global $wgDeleteRevisionsLimit; + $this->getContext()->getOutput()->wrapWikiMsg( "<div class='error'>\n$1\n</div>\n", + array( + 'delete-warning-toobig', + $this->getContext()->getLanguage()->formatNum( $wgDeleteRevisionsLimit ) + ) + ); + } + } + + $this->confirmDelete( $reason ); + } + + /** + * Output deletion confirmation dialog + * @todo FIXME: Move to another file? + * @param string $reason Prefilled reason + */ + public function confirmDelete( $reason ) { + wfDebug( "Article::confirmDelete\n" ); + + $title = $this->getTitle(); + $outputPage = $this->getContext()->getOutput(); + $outputPage->setPageTitle( wfMessage( 'delete-confirm', $title->getPrefixedText() ) ); + $outputPage->addBacklinkSubtitle( $title ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + $backlinkCache = $title->getBacklinkCache(); + if ( $backlinkCache->hasLinks( 'pagelinks' ) || $backlinkCache->hasLinks( 'templatelinks' ) ) { + $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + 'deleting-backlinks-warning' ); + } + $outputPage->addWikiMsg( 'confirmdeletetext' ); + + wfRunHooks( 'ArticleConfirmDelete', array( $this, $outputPage, &$reason ) ); + + $user = $this->getContext()->getUser(); + + if ( $user->isAllowed( 'suppressrevision' ) ) { + $suppress = "<tr id=\"wpDeleteSuppressRow\"> + <td></td> + <td class='mw-input'><strong>" . + Xml::checkLabel( wfMessage( 'revdelete-suppress' )->text(), + 'wpSuppress', 'wpSuppress', false, array( 'tabindex' => '4' ) ) . + "</strong></td> + </tr>"; + } else { + $suppress = ''; + } + $checkWatch = $user->getBoolOption( 'watchdeletion' ) || $user->isWatched( $title ); + + $form = Xml::openElement( 'form', array( 'method' => 'post', + 'action' => $title->getLocalURL( 'action=delete' ), 'id' => 'deleteconfirm' ) ) . + Xml::openElement( 'fieldset', array( 'id' => 'mw-delete-table' ) ) . + Xml::tags( 'legend', null, wfMessage( 'delete-legend' )->escaped() ) . + Xml::openElement( 'table', array( 'id' => 'mw-deleteconfirm-table' ) ) . + "<tr id=\"wpDeleteReasonListRow\"> + <td class='mw-label'>" . + Xml::label( wfMessage( 'deletecomment' )->text(), 'wpDeleteReasonList' ) . + "</td> + <td class='mw-input'>" . + Xml::listDropDown( + 'wpDeleteReasonList', + wfMessage( 'deletereason-dropdown' )->inContentLanguage()->text(), + wfMessage( 'deletereasonotherlist' )->inContentLanguage()->text(), + '', + 'wpReasonDropDown', + 1 + ) . + "</td> + </tr> + <tr id=\"wpDeleteReasonRow\"> + <td class='mw-label'>" . + Xml::label( wfMessage( 'deleteotherreason' )->text(), 'wpReason' ) . + "</td> + <td class='mw-input'>" . + Html::input( 'wpReason', $reason, 'text', array( + 'size' => '60', + 'maxlength' => '255', + 'tabindex' => '2', + 'id' => 'wpReason', + 'autofocus' + ) ) . + "</td> + </tr>"; + + # Disallow watching if user is not logged in + if ( $user->isLoggedIn() ) { + $form .= " + <tr> + <td></td> + <td class='mw-input'>" . + Xml::checkLabel( wfMessage( 'watchthis' )->text(), + 'wpWatch', 'wpWatch', $checkWatch, array( 'tabindex' => '3' ) ) . + "</td> + </tr>"; + } + + $form .= " + $suppress + <tr> + <td></td> + <td class='mw-submit'>" . + Xml::submitButton( wfMessage( 'deletepage' )->text(), + array( 'name' => 'wpConfirmB', 'id' => 'wpConfirmB', 'tabindex' => '5' ) ) . + "</td> + </tr>" . + Xml::closeElement( 'table' ) . + Xml::closeElement( 'fieldset' ) . + Html::hidden( + 'wpEditToken', + $user->getEditToken( array( 'delete', $title->getPrefixedText() ) ) + ) . + Xml::closeElement( 'form' ); + + if ( $user->isAllowed( 'editinterface' ) ) { + $dropdownTitle = Title::makeTitle( NS_MEDIAWIKI, 'Deletereason-dropdown' ); + $link = Linker::link( + $dropdownTitle, + wfMessage( 'delete-edit-reasonlist' )->escaped(), + array(), + array( 'action' => 'edit' ) + ); + $form .= '<p class="mw-delete-editreasons">' . $link . '</p>'; + } + + $outputPage->addHTML( $form ); + + $deleteLogPage = new LogPage( 'delete' ); + $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) ); + LogEventsList::showLogExtract( $outputPage, 'delete', $title ); + } + + /** + * Perform a deletion and output success or failure messages + * @param string $reason + * @param bool $suppress + */ + public function doDelete( $reason, $suppress = false ) { + $error = ''; + $outputPage = $this->getContext()->getOutput(); + $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error ); + + if ( $status->isGood() ) { + $deleted = $this->getTitle()->getPrefixedText(); + + $outputPage->setPageTitle( wfMessage( 'actioncomplete' ) ); + $outputPage->setRobotPolicy( 'noindex,nofollow' ); + + $loglink = '[[Special:Log/delete|' . wfMessage( 'deletionlog' )->text() . ']]'; + + $outputPage->addWikiMsg( 'deletedtext', wfEscapeWikiText( $deleted ), $loglink ); + $outputPage->returnToMain( false ); + } else { + $outputPage->setPageTitle( + wfMessage( 'cannotdelete-title', + $this->getTitle()->getPrefixedText() ) + ); + + if ( $error == '' ) { + $outputPage->addWikiText( + "<div class=\"error mw-error-cannotdelete\">\n" . $status->getWikiText() . "\n</div>" + ); + $deleteLogPage = new LogPage( 'delete' ); + $outputPage->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) ); + + LogEventsList::showLogExtract( + $outputPage, + 'delete', + $this->getTitle() + ); + } else { + $outputPage->addHTML( $error ); + } + } + } + + /* Caching functions */ + + /** + * checkLastModified returns true if it has taken care of all + * output to the client that is necessary for this request. + * (that is, it has sent a cached version of the page) + * + * @return bool True if cached version send, false otherwise + */ + protected function tryFileCache() { + static $called = false; + + if ( $called ) { + wfDebug( "Article::tryFileCache(): called twice!?\n" ); + return false; + } + + $called = true; + if ( $this->isFileCacheable() ) { + $cache = new HTMLFileCache( $this->getTitle(), 'view' ); + if ( $cache->isCacheGood( $this->mPage->getTouched() ) ) { + wfDebug( "Article::tryFileCache(): about to load file\n" ); + $cache->loadFromFileCache( $this->getContext() ); + return true; + } else { + wfDebug( "Article::tryFileCache(): starting buffer\n" ); + ob_start( array( &$cache, 'saveToFileCache' ) ); + } + } else { + wfDebug( "Article::tryFileCache(): not cacheable\n" ); + } + + return false; + } + + /** + * Check if the page can be cached + * @return bool + */ + public function isFileCacheable() { + $cacheable = false; + + if ( HTMLFileCache::useFileCache( $this->getContext() ) ) { + $cacheable = $this->mPage->getID() + && !$this->mRedirectedFrom && !$this->getTitle()->isRedirect(); + // Extension may have reason to disable file caching on some pages. + if ( $cacheable ) { + $cacheable = wfRunHooks( 'IsFileCacheable', array( &$this ) ); + } + } + + return $cacheable; + } + + /**#@-*/ + + /** + * Lightweight method to get the parser output for a page, checking the parser cache + * and so on. Doesn't consider most of the stuff that WikiPage::view is forced to + * consider, so it's not appropriate to use there. + * + * @since 1.16 (r52326) for LiquidThreads + * + * @param int|null $oldid Revision ID or null + * @param User $user The relevant user + * @return ParserOutput|bool ParserOutput or false if the given revision ID is not found + */ + public function getParserOutput( $oldid = null, User $user = null ) { + //XXX: bypasses mParserOptions and thus setParserOptions() + + if ( $user === null ) { + $parserOptions = $this->getParserOptions(); + } else { + $parserOptions = $this->mPage->makeParserOptions( $user ); + } + + return $this->mPage->getParserOutput( $parserOptions, $oldid ); + } + + /** + * Override the ParserOptions used to render the primary article wikitext. + * + * @param ParserOptions $options + * @throws MWException If the parser options where already initialized. + */ + public function setParserOptions( ParserOptions $options ) { + if ( $this->mParserOptions ) { + throw new MWException( "can't change parser options after they have already been set" ); + } + + // clone, so if $options is modified later, it doesn't confuse the parser cache. + $this->mParserOptions = clone $options; + } + + /** + * Get parser options suitable for rendering the primary article wikitext + * @return ParserOptions + */ + public function getParserOptions() { + if ( !$this->mParserOptions ) { + $this->mParserOptions = $this->mPage->makeParserOptions( $this->getContext() ); + } + // Clone to allow modifications of the return value without affecting cache + return clone $this->mParserOptions; + } + + /** + * Sets the context this Article is executed in + * + * @param IContextSource $context + * @since 1.18 + */ + public function setContext( $context ) { + $this->mContext = $context; + } + + /** + * Gets the context this Article is executed in + * + * @return IContextSource + * @since 1.18 + */ + public function getContext() { + if ( $this->mContext instanceof IContextSource ) { + return $this->mContext; + } else { + wfDebug( __METHOD__ . " called and \$mContext is null. " . + "Return RequestContext::getMain(); for sanity\n" ); + return RequestContext::getMain(); + } + } + + /** + * Use PHP's magic __get handler to handle accessing of + * raw WikiPage fields for backwards compatibility. + * + * @param string $fname Field name + * @return mixed + */ + public function __get( $fname ) { + if ( property_exists( $this->mPage, $fname ) ) { + #wfWarn( "Access to raw $fname field " . __CLASS__ ); + return $this->mPage->$fname; + } + trigger_error( 'Inaccessible property via __get(): ' . $fname, E_USER_NOTICE ); + } + + /** + * Use PHP's magic __set handler to handle setting of + * raw WikiPage fields for backwards compatibility. + * + * @param string $fname Field name + * @param mixed $fvalue New value + */ + public function __set( $fname, $fvalue ) { + if ( property_exists( $this->mPage, $fname ) ) { + #wfWarn( "Access to raw $fname field of " . __CLASS__ ); + $this->mPage->$fname = $fvalue; + // Note: extensions may want to toss on new fields + } elseif ( !in_array( $fname, array( 'mContext', 'mPage' ) ) ) { + $this->mPage->$fname = $fvalue; + } else { + trigger_error( 'Inaccessible property via __set(): ' . $fname, E_USER_NOTICE ); + } + } + + /** + * Use PHP's magic __call handler to transform instance calls to + * WikiPage functions for backwards compatibility. + * + * @param string $fname Name of called method + * @param array $args Arguments to the method + * @return mixed + */ + public function __call( $fname, $args ) { + if ( is_callable( array( $this->mPage, $fname ) ) ) { + #wfWarn( "Call to " . __CLASS__ . "::$fname; please use WikiPage instead" ); + return call_user_func_array( array( $this->mPage, $fname ), $args ); + } + trigger_error( 'Inaccessible function via __call(): ' . $fname, E_USER_ERROR ); + } + + // ****** B/C functions to work-around PHP silliness with __call and references ****** // + + /** + * @param array $limit + * @param array $expiry + * @param bool $cascade + * @param string $reason + * @param User $user + * @return Status + */ + public function doUpdateRestrictions( array $limit, array $expiry, &$cascade, + $reason, User $user + ) { + return $this->mPage->doUpdateRestrictions( $limit, $expiry, $cascade, $reason, $user ); + } + + /** + * @param array $limit + * @param string $reason + * @param int $cascade + * @param array $expiry + * @return bool + */ + public function updateRestrictions( $limit = array(), $reason = '', + &$cascade = 0, $expiry = array() + ) { + return $this->mPage->doUpdateRestrictions( + $limit, + $expiry, + $cascade, + $reason, + $this->getContext()->getUser() + ); + } + + /** + * @param string $reason + * @param bool $suppress + * @param int $id + * @param bool $commit + * @param string $error + * @return bool + */ + public function doDeleteArticle( $reason, $suppress = false, $id = 0, + $commit = true, &$error = '' + ) { + return $this->mPage->doDeleteArticle( $reason, $suppress, $id, $commit, $error ); + } + + /** + * @param string $fromP + * @param string $summary + * @param string $token + * @param bool $bot + * @param array $resultDetails + * @param User|null $user + * @return array + */ + public function doRollback( $fromP, $summary, $token, $bot, &$resultDetails, User $user = null ) { + $user = is_null( $user ) ? $this->getContext()->getUser() : $user; + return $this->mPage->doRollback( $fromP, $summary, $token, $bot, $resultDetails, $user ); + } + + /** + * @param string $fromP + * @param string $summary + * @param bool $bot + * @param array $resultDetails + * @param User|null $guser + * @return array + */ + public function commitRollback( $fromP, $summary, $bot, &$resultDetails, User $guser = null ) { + $guser = is_null( $guser ) ? $this->getContext()->getUser() : $guser; + return $this->mPage->commitRollback( $fromP, $summary, $bot, $resultDetails, $guser ); + } + + /** + * @param bool $hasHistory + * @return mixed + */ + public function generateReason( &$hasHistory ) { + $title = $this->mPage->getTitle(); + $handler = ContentHandler::getForTitle( $title ); + return $handler->getAutoDeleteReason( $title, $hasHistory ); + } + + // ****** B/C functions for static methods ( __callStatic is PHP>=5.3 ) ****** // + + /** + * @return array + * + * @deprecated since 1.24, use WikiPage::selectFields() instead + */ + public static function selectFields() { + wfDeprecated( __METHOD__, '1.24' ); + return WikiPage::selectFields(); + } + + /** + * @param Title $title + * + * @deprecated since 1.24, use WikiPage::onArticleCreate() instead + */ + public static function onArticleCreate( $title ) { + wfDeprecated( __METHOD__, '1.24' ); + WikiPage::onArticleCreate( $title ); + } + + /** + * @param Title $title + * + * @deprecated since 1.24, use WikiPage::onArticleDelete() instead + */ + public static function onArticleDelete( $title ) { + wfDeprecated( __METHOD__, '1.24' ); + WikiPage::onArticleDelete( $title ); + } + + /** + * @param Title $title + * + * @deprecated since 1.24, use WikiPage::onArticleEdit() instead + */ + public static function onArticleEdit( $title ) { + wfDeprecated( __METHOD__, '1.24' ); + WikiPage::onArticleEdit( $title ); + } + + /** + * @param string $oldtext + * @param string $newtext + * @param int $flags + * @return string + * @deprecated since 1.21, use ContentHandler::getAutosummary() instead + */ + public static function getAutosummary( $oldtext, $newtext, $flags ) { + return WikiPage::getAutosummary( $oldtext, $newtext, $flags ); + } + // ****** +} diff --git a/includes/page/CategoryPage.php b/includes/page/CategoryPage.php new file mode 100644 index 00000000..9abc6a89 --- /dev/null +++ b/includes/page/CategoryPage.php @@ -0,0 +1,118 @@ +<?php +/** + * Special handling for category description pages. + * Modelled after ImagePage.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 + */ + +/** + * Special handling for category description pages, showing pages, + * subcategories and file that belong to the category + */ +class CategoryPage extends Article { + # Subclasses can change this to override the viewer class. + protected $mCategoryViewerClass = 'CategoryViewer'; + + /** + * @param Title $title + * @return WikiCategoryPage + */ + protected function newPage( Title $title ) { + // Overload mPage with a category-specific page + return new WikiCategoryPage( $title ); + } + + /** + * Constructor from a page id + * @param int $id Article ID to load + * @return CategoryPage|null + */ + public static function newFromID( $id ) { + $t = Title::newFromID( $id ); + # @todo FIXME: Doesn't inherit right + return $t == null ? null : new self( $t ); + # return $t == null ? null : new static( $t ); // PHP 5.3 + } + + function view() { + $request = $this->getContext()->getRequest(); + $diff = $request->getVal( 'diff' ); + $diffOnly = $request->getBool( 'diffonly', + $this->getContext()->getUser()->getOption( 'diffonly' ) ); + + if ( $diff !== null && $diffOnly ) { + parent::view(); + return; + } + + if ( !wfRunHooks( 'CategoryPageView', array( &$this ) ) ) { + return; + } + + $title = $this->getTitle(); + if ( NS_CATEGORY == $title->getNamespace() ) { + $this->openShowCategory(); + } + + parent::view(); + + if ( NS_CATEGORY == $title->getNamespace() ) { + $this->closeShowCategory(); + } + } + + function openShowCategory() { + # For overloading + } + + function closeShowCategory() { + // Use these as defaults for back compat --catrope + $request = $this->getContext()->getRequest(); + $oldFrom = $request->getVal( 'from' ); + $oldUntil = $request->getVal( 'until' ); + + $reqArray = $request->getValues(); + + $from = $until = array(); + foreach ( array( 'page', 'subcat', 'file' ) as $type ) { + $from[$type] = $request->getVal( "{$type}from", $oldFrom ); + $until[$type] = $request->getVal( "{$type}until", $oldUntil ); + + // Do not want old-style from/until propagating in nav links. + if ( !isset( $reqArray["{$type}from"] ) && isset( $reqArray["from"] ) ) { + $reqArray["{$type}from"] = $reqArray["from"]; + } + if ( !isset( $reqArray["{$type}to"] ) && isset( $reqArray["to"] ) ) { + $reqArray["{$type}to"] = $reqArray["to"]; + } + } + + unset( $reqArray["from"] ); + unset( $reqArray["to"] ); + + $viewer = new $this->mCategoryViewerClass( + $this->getContext()->getTitle(), + $this->getContext(), + $from, + $until, + $reqArray + ); + $this->getContext()->getOutput()->addHTML( $viewer->getHTML() ); + } +} diff --git a/includes/page/ImagePage.php b/includes/page/ImagePage.php new file mode 100644 index 00000000..d06c8191 --- /dev/null +++ b/includes/page/ImagePage.php @@ -0,0 +1,1615 @@ +<?php +/** + * Special handling for file description pages. + * + * 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 for viewing MediaWiki file description pages + * + * @ingroup Media + */ +class ImagePage extends Article { + /** @var File */ + private $displayImg; + + /** @var FileRepo */ + private $repo; + + /** @var bool */ + private $fileLoaded; + + /** @var bool */ + protected $mExtraDescription = false; + + /** + * @param Title $title + * @return WikiFilePage + */ + protected function newPage( Title $title ) { + // Overload mPage with a file-specific page + return new WikiFilePage( $title ); + } + + /** + * Constructor from a page id + * @param int $id Article ID to load + * @return ImagePage|null + */ + public static function newFromID( $id ) { + $t = Title::newFromID( $id ); + # @todo FIXME: Doesn't inherit right + return $t == null ? null : new self( $t ); + # return $t == null ? null : new static( $t ); // PHP 5.3 + } + + /** + * @param File $file + * @return void + */ + public function setFile( $file ) { + $this->mPage->setFile( $file ); + $this->displayImg = $file; + $this->fileLoaded = true; + } + + protected function loadFile() { + if ( $this->fileLoaded ) { + return; + } + $this->fileLoaded = true; + + $this->displayImg = $img = false; + wfRunHooks( 'ImagePageFindFile', array( $this, &$img, &$this->displayImg ) ); + if ( !$img ) { // not set by hook? + $img = wfFindFile( $this->getTitle() ); + if ( !$img ) { + $img = wfLocalFile( $this->getTitle() ); + } + } + $this->mPage->setFile( $img ); + if ( !$this->displayImg ) { // not set by hook? + $this->displayImg = $img; + } + $this->repo = $img->getRepo(); + } + + /** + * Handler for action=render + * Include body text only; none of the image extras + */ + public function render() { + $this->getContext()->getOutput()->setArticleBodyOnly( true ); + parent::view(); + } + + public function view() { + global $wgShowEXIF; + + $out = $this->getContext()->getOutput(); + $request = $this->getContext()->getRequest(); + $diff = $request->getVal( 'diff' ); + $diffOnly = $request->getBool( + 'diffonly', + $this->getContext()->getUser()->getOption( 'diffonly' ) + ); + + if ( $this->getTitle()->getNamespace() != NS_FILE || ( $diff !== null && $diffOnly ) ) { + parent::view(); + return; + } + + $this->loadFile(); + + if ( $this->getTitle()->getNamespace() == NS_FILE && $this->mPage->getFile()->getRedirected() ) { + if ( $this->getTitle()->getDBkey() == $this->mPage->getFile()->getName() || $diff !== null ) { + // mTitle is the same as the redirect target so ask Article + // to perform the redirect for us. + $request->setVal( 'diffonly', 'true' ); + parent::view(); + return; + } else { + // mTitle is not the same as the redirect target so it is + // probably the redirect page itself. Fake the redirect symbol + $out->setPageTitle( $this->getTitle()->getPrefixedText() ); + $out->addHTML( $this->viewRedirect( + Title::makeTitle( NS_FILE, $this->mPage->getFile()->getName() ), + /* $appendSubtitle */ true, + /* $forceKnown */ true ) + ); + $this->mPage->doViewUpdates( $this->getContext()->getUser(), $this->getOldID() ); + return; + } + } + + if ( $wgShowEXIF && $this->displayImg->exists() ) { + // @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata(). + $formattedMetadata = $this->displayImg->formatMetadata(); + $showmeta = $formattedMetadata !== false; + } else { + $showmeta = false; + } + + if ( !$diff && $this->displayImg->exists() ) { + $out->addHTML( $this->showTOC( $showmeta ) ); + } + + if ( !$diff ) { + $this->openShowImage(); + } + + # No need to display noarticletext, we use our own message, output in openShowImage() + if ( $this->mPage->getID() ) { + # NS_FILE is in the user language, but this section (the actual wikitext) + # should be in page content language + $pageLang = $this->getTitle()->getPageViewLanguage(); + $out->addHTML( Xml::openElement( 'div', array( 'id' => 'mw-imagepage-content', + 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(), + 'class' => 'mw-content-' . $pageLang->getDir() ) ) ); + + parent::view(); + + $out->addHTML( Xml::closeElement( 'div' ) ); + } else { + # Just need to set the right headers + $out->setArticleFlag( true ); + $out->setPageTitle( $this->getTitle()->getPrefixedText() ); + $this->mPage->doViewUpdates( $this->getContext()->getUser(), $this->getOldID() ); + } + + # Show shared description, if needed + if ( $this->mExtraDescription ) { + $fol = wfMessage( 'shareddescriptionfollows' ); + if ( !$fol->isDisabled() ) { + $out->addWikiText( $fol->plain() ); + } + $out->addHTML( '<div id="shared-image-desc">' . $this->mExtraDescription . "</div>\n" ); + } + + $this->closeShowImage(); + $this->imageHistory(); + // TODO: Cleanup the following + + $out->addHTML( Xml::element( 'h2', + array( 'id' => 'filelinks' ), + wfMessage( 'imagelinks' )->text() ) . "\n" ); + $this->imageDupes(); + # @todo FIXME: For some freaky reason, we can't redirect to foreign images. + # Yet we return metadata about the target. Definitely an issue in the FileRepo + $this->imageLinks(); + + # Allow extensions to add something after the image links + $html = ''; + wfRunHooks( 'ImagePageAfterImageLinks', array( $this, &$html ) ); + if ( $html ) { + $out->addHTML( $html ); + } + + if ( $showmeta ) { + $out->addHTML( Xml::element( + 'h2', + array( 'id' => 'metadata' ), + wfMessage( 'metadata' )->text() ) . "\n" ); + $out->addWikiText( $this->makeMetadataTable( $formattedMetadata ) ); + $out->addModules( array( 'mediawiki.action.view.metadata' ) ); + } + + // Add remote Filepage.css + if ( !$this->repo->isLocal() ) { + $css = $this->repo->getDescriptionStylesheetUrl(); + if ( $css ) { + $out->addStyle( $css ); + } + } + // always show the local local Filepage.css, bug 29277 + $out->addModuleStyles( 'filepage' ); + } + + /** + * @return File + */ + public function getDisplayedFile() { + $this->loadFile(); + return $this->displayImg; + } + + /** + * Create the TOC + * + * @param bool $metadata Whether or not to show the metadata link + * @return string + */ + protected function showTOC( $metadata ) { + $r = array( + '<li><a href="#file">' . wfMessage( 'file-anchor-link' )->escaped() . '</a></li>', + '<li><a href="#filehistory">' . wfMessage( 'filehist' )->escaped() . '</a></li>', + '<li><a href="#filelinks">' . wfMessage( 'imagelinks' )->escaped() . '</a></li>', + ); + if ( $metadata ) { + $r[] = '<li><a href="#metadata">' . wfMessage( 'metadata' )->escaped() . '</a></li>'; + } + + wfRunHooks( 'ImagePageShowTOC', array( $this, &$r ) ); + + return '<ul id="filetoc">' . implode( "\n", $r ) . '</ul>'; + } + + /** + * Make a table with metadata to be shown in the output page. + * + * @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata(). + * + * @param array $metadata The array containing the Exif data + * @return string The metadata table. This is treated as Wikitext (!) + */ + protected function makeMetadataTable( $metadata ) { + $r = "<div class=\"mw-imagepage-section-metadata\">"; + $r .= wfMessage( 'metadata-help' )->plain(); + $r .= "<table id=\"mw_metadata\" class=\"mw_metadata\">\n"; + foreach ( $metadata as $type => $stuff ) { + foreach ( $stuff as $v ) { + # @todo FIXME: Why is this using escapeId for a class?! + $class = Sanitizer::escapeId( $v['id'] ); + if ( $type == 'collapsed' ) { + // Handled by mediawiki.action.view.metadata module. + $class .= ' collapsable'; + } + $r .= "<tr class=\"$class\">\n"; + $r .= "<th>{$v['name']}</th>\n"; + $r .= "<td>{$v['value']}</td>\n</tr>"; + } + } + $r .= "</table>\n</div>\n"; + return $r; + } + + /** + * Overloading Article's getContentObject method. + * + * Omit noarticletext if sharedupload; text will be fetched from the + * shared upload server if possible. + * @return string + */ + public function getContentObject() { + $this->loadFile(); + if ( $this->mPage->getFile() && !$this->mPage->getFile()->isLocal() && 0 == $this->getID() ) { + return null; + } + return parent::getContentObject(); + } + + protected function openShowImage() { + global $wgEnableUploads, $wgSend404Code; + + $this->loadFile(); + $out = $this->getContext()->getOutput(); + $user = $this->getContext()->getUser(); + $lang = $this->getContext()->getLanguage(); + $dirmark = $lang->getDirMarkEntity(); + $request = $this->getContext()->getRequest(); + + $max = $this->getImageLimitsFromOption( $user, 'imagesize' ); + $maxWidth = $max[0]; + $maxHeight = $max[1]; + + if ( $this->displayImg->exists() ) { + # image + $page = $request->getIntOrNull( 'page' ); + if ( is_null( $page ) ) { + $params = array(); + $page = 1; + } else { + $params = array( 'page' => $page ); + } + + $renderLang = $request->getVal( 'lang' ); + if ( !is_null( $renderLang ) ) { + $handler = $this->displayImg->getHandler(); + if ( $handler && $handler->validateParam( 'lang', $renderLang ) ) { + $params['lang'] = $renderLang; + } else { + $renderLang = null; + } + } + + $width_orig = $this->displayImg->getWidth( $page ); + $width = $width_orig; + $height_orig = $this->displayImg->getHeight( $page ); + $height = $height_orig; + + $filename = wfEscapeWikiText( $this->displayImg->getName() ); + $linktext = $filename; + + wfRunHooks( 'ImageOpenShowImageInlineBefore', array( &$this, &$out ) ); + + if ( $this->displayImg->allowInlineDisplay() ) { + # image + # "Download high res version" link below the image + # $msgsize = wfMessage( 'file-info-size', $width_orig, $height_orig, + # Linker::formatSize( $this->displayImg->getSize() ), $mime )->escaped(); + # We'll show a thumbnail of this image + if ( $width > $maxWidth || $height > $maxHeight || $this->displayImg->isVectorized() ) { + list( $width, $height ) = $this->getDisplayWidthHeight( + $maxWidth, $maxHeight, $width, $height + ); + $linktext = wfMessage( 'show-big-image' )->escaped(); + + $thumbSizes = $this->getThumbSizes( $width, $height, $width_orig, $height_orig ); + # Generate thumbnails or thumbnail links as needed... + $otherSizes = array(); + foreach ( $thumbSizes as $size ) { + // We include a thumbnail size in the list, if it is + // less than or equal to the original size of the image + // asset ($width_orig/$height_orig). We also exclude + // the current thumbnail's size ($width/$height) + // since that is added to the message separately, so + // it can be denoted as the current size being shown. + // Vectorized images are "infinitely" big, so all thumb + // sizes are shown. + if ( ( ( $size[0] <= $width_orig && $size[1] <= $height_orig ) + || $this->displayImg->isVectorized() ) + && $size[0] != $width && $size[1] != $height + ) { + $sizeLink = $this->makeSizeLink( $params, $size[0], $size[1] ); + if ( $sizeLink ) { + $otherSizes[] = $sizeLink; + } + } + } + $otherSizes = array_unique( $otherSizes ); + + $msgsmall = ''; + $sizeLinkBigImagePreview = $this->makeSizeLink( $params, $width, $height ); + if ( $sizeLinkBigImagePreview ) { + $msgsmall .= wfMessage( 'show-big-image-preview' )-> + rawParams( $sizeLinkBigImagePreview )-> + parse(); + } + if ( count( $otherSizes ) ) { + $msgsmall .= ' ' . + Html::rawElement( 'span', array( 'class' => 'mw-filepage-other-resolutions' ), + wfMessage( 'show-big-image-other' )->rawParams( $lang->pipeList( $otherSizes ) )-> + params( count( $otherSizes ) )->parse() + ); + } + } elseif ( $width == 0 && $height == 0 ) { + # Some sort of audio file that doesn't have dimensions + # Don't output a no hi res message for such a file + $msgsmall = ''; + } else { + # Image is small enough to show full size on image page + $msgsmall = wfMessage( 'file-nohires' )->parse(); + } + + $params['width'] = $width; + $params['height'] = $height; + $thumbnail = $this->displayImg->transform( $params ); + Linker::processResponsiveImages( $this->displayImg, $thumbnail, $params ); + + $anchorclose = Html::rawElement( + 'div', + array( 'class' => 'mw-filepage-resolutioninfo' ), + $msgsmall + ); + + $isMulti = $this->displayImg->isMultipage() && $this->displayImg->pageCount() > 1; + if ( $isMulti ) { + $out->addModules( 'mediawiki.page.image.pagination' ); + $out->addHTML( '<table class="multipageimage"><tr><td>' ); + } + + if ( $thumbnail ) { + $options = array( + 'alt' => $this->displayImg->getTitle()->getPrefixedText(), + 'file-link' => true, + ); + $out->addHTML( '<div class="fullImageLink" id="file">' . + $thumbnail->toHtml( $options ) . + $anchorclose . "</div>\n" ); + } + + if ( $isMulti ) { + $count = $this->displayImg->pageCount(); + + if ( $page > 1 ) { + $label = $out->parse( wfMessage( 'imgmultipageprev' )->text(), false ); + // on the client side, this link is generated in ajaxifyPageNavigation() + // in the mediawiki.page.image.pagination module + $link = Linker::linkKnown( + $this->getTitle(), + $label, + array(), + array( 'page' => $page - 1 ) + ); + $thumb1 = Linker::makeThumbLinkObj( + $this->getTitle(), + $this->displayImg, + $link, + $label, + 'none', + array( 'page' => $page - 1 ) + ); + } else { + $thumb1 = ''; + } + + if ( $page < $count ) { + $label = wfMessage( 'imgmultipagenext' )->text(); + $link = Linker::linkKnown( + $this->getTitle(), + $label, + array(), + array( 'page' => $page + 1 ) + ); + $thumb2 = Linker::makeThumbLinkObj( + $this->getTitle(), + $this->displayImg, + $link, + $label, + 'none', + array( 'page' => $page + 1 ) + ); + } else { + $thumb2 = ''; + } + + global $wgScript; + + $formParams = array( + 'name' => 'pageselector', + 'action' => $wgScript, + ); + $options = array(); + for ( $i = 1; $i <= $count; $i++ ) { + $options[] = Xml::option( $lang->formatNum( $i ), $i, $i == $page ); + } + $select = Xml::tags( 'select', + array( 'id' => 'pageselector', 'name' => 'page' ), + implode( "\n", $options ) ); + + $out->addHTML( + '</td><td><div class="multipageimagenavbox">' . + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . + wfMessage( 'imgmultigoto' )->rawParams( $select )->parse() . + Xml::submitButton( wfMessage( 'imgmultigo' )->text() ) . + Xml::closeElement( 'form' ) . + "<hr />$thumb1\n$thumb2<br style=\"clear: both\" /></div></td></tr></table>" + ); + } + } elseif ( $this->displayImg->isSafeFile() ) { + # if direct link is allowed but it's not a renderable image, show an icon. + $icon = $this->displayImg->iconThumb(); + + $out->addHTML( '<div class="fullImageLink" id="file">' . + $icon->toHtml( array( 'file-link' => true ) ) . + "</div>\n" ); + } + + $longDesc = wfMessage( 'parentheses', $this->displayImg->getLongDesc() )->text(); + + $medialink = "[[Media:$filename|$linktext]]"; + + if ( !$this->displayImg->isSafeFile() ) { + $warning = wfMessage( 'mediawarning' )->plain(); + // dirmark is needed here to separate the file name, which + // most likely ends in Latin characters, from the description, + // which may begin with the file type. In RTL environment + // this will get messy. + // The dirmark, however, must not be immediately adjacent + // to the filename, because it can get copied with it. + // See bug 25277. + // @codingStandardsIgnoreStart Ignore long line + $out->addWikiText( <<<EOT +<div class="fullMedia"><span class="dangerousLink">{$medialink}</span> $dirmark<span class="fileInfo">$longDesc</span></div> +<div class="mediaWarning">$warning</div> +EOT + ); + // @codingStandardsIgnoreEnd + } else { + $out->addWikiText( <<<EOT +<div class="fullMedia">{$medialink} {$dirmark}<span class="fileInfo">$longDesc</span> +</div> +EOT + ); + } + + $renderLangOptions = $this->displayImg->getAvailableLanguages(); + if ( count( $renderLangOptions ) >= 1 ) { + $currentLanguage = $renderLang; + $defaultLang = $this->displayImg->getDefaultRenderLanguage(); + if ( is_null( $currentLanguage ) ) { + $currentLanguage = $defaultLang; + } + $out->addHtml( $this->doRenderLangOpt( $renderLangOptions, $currentLanguage, $defaultLang ) ); + } + + // Add cannot animate thumbnail warning + if ( !$this->displayImg->canAnimateThumbIfAppropriate() ) { + // Include the extension so wiki admins can + // customize it on a per file-type basis + // (aka say things like use format X instead). + // additionally have a specific message for + // file-no-thumb-animation-gif + $ext = $this->displayImg->getExtension(); + $noAnimMesg = wfMessageFallback( + 'file-no-thumb-animation-' . $ext, + 'file-no-thumb-animation' + )->plain(); + + $out->addWikiText( <<<EOT +<div class="mw-noanimatethumb">{$noAnimMesg}</div> +EOT + ); + } + + if ( !$this->displayImg->isLocal() ) { + $this->printSharedImageText(); + } + } else { + # Image does not exist + if ( !$this->getID() ) { + # No article exists either + # Show deletion log to be consistent with normal articles + LogEventsList::showLogExtract( + $out, + array( 'delete', 'move' ), + $this->getTitle()->getPrefixedText(), + '', + array( 'lim' => 10, + 'conds' => array( "log_action != 'revision'" ), + 'showIfEmpty' => false, + 'msgKey' => array( 'moveddeleted-notice' ) + ) + ); + } + + if ( $wgEnableUploads && $user->isAllowed( 'upload' ) ) { + // Only show an upload link if the user can upload + $uploadTitle = SpecialPage::getTitleFor( 'Upload' ); + $nofile = array( + 'filepage-nofile-link', + $uploadTitle->getFullURL( array( 'wpDestFile' => $this->mPage->getFile()->getName() ) ) + ); + } else { + $nofile = 'filepage-nofile'; + } + // Note, if there is an image description page, but + // no image, then this setRobotPolicy is overridden + // by Article::View(). + $out->setRobotPolicy( 'noindex,nofollow' ); + $out->wrapWikiMsg( "<div id='mw-imagepage-nofile' class='plainlinks'>\n$1\n</div>", $nofile ); + if ( !$this->getID() && $wgSend404Code ) { + // If there is no image, no shared image, and no description page, + // output a 404, to be consistent with articles. + $request->response()->header( 'HTTP/1.1 404 Not Found' ); + } + } + $out->setFileVersion( $this->displayImg ); + } + + /** + * Creates an thumbnail of specified size and returns an HTML link to it + * @param array $params Scaler parameters + * @param int $width + * @param int $height + * @return string + */ + private function makeSizeLink( $params, $width, $height ) { + $params['width'] = $width; + $params['height'] = $height; + $thumbnail = $this->displayImg->transform( $params ); + if ( $thumbnail && !$thumbnail->isError() ) { + return Html::rawElement( 'a', array( + 'href' => $thumbnail->getUrl(), + 'class' => 'mw-thumbnail-link' + ), wfMessage( 'show-big-image-size' )->numParams( + $thumbnail->getWidth(), $thumbnail->getHeight() + )->parse() ); + } else { + return ''; + } + } + + /** + * Show a notice that the file is from a shared repository + */ + protected function printSharedImageText() { + $out = $this->getContext()->getOutput(); + $this->loadFile(); + + $descUrl = $this->mPage->getFile()->getDescriptionUrl(); + $descText = $this->mPage->getFile()->getDescriptionText( $this->getContext()->getLanguage() ); + + /* Add canonical to head if there is no local page for this shared file */ + if ( $descUrl && $this->mPage->getID() == 0 ) { + $out->setCanonicalUrl( $descUrl ); + } + + $wrap = "<div class=\"sharedUploadNotice\">\n$1\n</div>\n"; + $repo = $this->mPage->getFile()->getRepo()->getDisplayName(); + + if ( $descUrl && $descText && wfMessage( 'sharedupload-desc-here' )->plain() !== '-' ) { + $out->wrapWikiMsg( $wrap, array( 'sharedupload-desc-here', $repo, $descUrl ) ); + } elseif ( $descUrl && wfMessage( 'sharedupload-desc-there' )->plain() !== '-' ) { + $out->wrapWikiMsg( $wrap, array( 'sharedupload-desc-there', $repo, $descUrl ) ); + } else { + $out->wrapWikiMsg( $wrap, array( 'sharedupload', $repo ), ''/*BACKCOMPAT*/ ); + } + + if ( $descText ) { + $this->mExtraDescription = $descText; + } + } + + public function getUploadUrl() { + $this->loadFile(); + $uploadTitle = SpecialPage::getTitleFor( 'Upload' ); + return $uploadTitle->getFullURL( array( + 'wpDestFile' => $this->mPage->getFile()->getName(), + 'wpForReUpload' => 1 + ) ); + } + + /** + * Print out the various links at the bottom of the image page, e.g. reupload, + * external editing (and instructions link) etc. + */ + protected function uploadLinksBox() { + global $wgEnableUploads; + + if ( !$wgEnableUploads ) { + return; + } + + $this->loadFile(); + if ( !$this->mPage->getFile()->isLocal() ) { + return; + } + + $out = $this->getContext()->getOutput(); + $out->addHTML( "<ul>\n" ); + + # "Upload a new version of this file" link + $canUpload = $this->getTitle()->userCan( 'upload', $this->getContext()->getUser() ); + if ( $canUpload && UploadBase::userCanReUpload( + $this->getContext()->getUser(), + $this->mPage->getFile()->name ) + ) { + $ulink = Linker::makeExternalLink( + $this->getUploadUrl(), + wfMessage( 'uploadnewversion-linktext' )->text() + ); + $out->addHTML( "<li id=\"mw-imagepage-reupload-link\">" + . "<div class=\"plainlinks\">{$ulink}</div></li>\n" ); + } else { + $out->addHTML( "<li id=\"mw-imagepage-upload-disallowed\">" + . $this->getContext()->msg( 'upload-disallowed-here' )->escaped() . "</li>\n" ); + } + + $out->addHTML( "</ul>\n" ); + } + + /** + * For overloading + */ + protected function closeShowImage() { + } + + /** + * If the page we've just displayed is in the "Image" namespace, + * we follow it with an upload history of the image and its usage. + */ + protected function imageHistory() { + $this->loadFile(); + $out = $this->getContext()->getOutput(); + $pager = new ImageHistoryPseudoPager( $this ); + $out->addHTML( $pager->getBody() ); + $out->preventClickjacking( $pager->getPreventClickjacking() ); + + $this->mPage->getFile()->resetHistory(); // free db resources + + # Exist check because we don't want to show this on pages where an image + # doesn't exist along with the noimage message, that would suck. -ævar + if ( $this->mPage->getFile()->exists() ) { + $this->uploadLinksBox(); + } + } + + /** + * @param string $target + * @param int $limit + * @return ResultWrapper + */ + protected function queryImageLinks( $target, $limit ) { + $dbr = wfGetDB( DB_SLAVE ); + + return $dbr->select( + array( 'imagelinks', 'page' ), + array( 'page_namespace', 'page_title', 'il_to' ), + array( 'il_to' => $target, 'il_from = page_id' ), + __METHOD__, + array( 'LIMIT' => $limit + 1, 'ORDER BY' => 'il_from', ) + ); + } + + protected function imageLinks() { + $limit = 100; + + $out = $this->getContext()->getOutput(); + + $rows = array(); + $redirects = array(); + foreach ( $this->getTitle()->getRedirectsHere( NS_FILE ) as $redir ) { + $redirects[$redir->getDBkey()] = array(); + $rows[] = (object)array( + 'page_namespace' => NS_FILE, + 'page_title' => $redir->getDBkey(), + ); + } + + $res = $this->queryImageLinks( $this->getTitle()->getDBkey(), $limit + 1 ); + foreach ( $res as $row ) { + $rows[] = $row; + } + $count = count( $rows ); + + $hasMore = $count > $limit; + if ( !$hasMore && count( $redirects ) ) { + $res = $this->queryImageLinks( array_keys( $redirects ), + $limit - count( $rows ) + 1 ); + foreach ( $res as $row ) { + $redirects[$row->il_to][] = $row; + $count++; + } + $hasMore = ( $res->numRows() + count( $rows ) ) > $limit; + } + + if ( $count == 0 ) { + $out->wrapWikiMsg( + Html::rawElement( 'div', + array( 'id' => 'mw-imagepage-nolinkstoimage' ), "\n$1\n" ), + 'nolinkstoimage' + ); + return; + } + + $out->addHTML( "<div id='mw-imagepage-section-linkstoimage'>\n" ); + if ( !$hasMore ) { + $out->addWikiMsg( 'linkstoimage', $count ); + } else { + // More links than the limit. Add a link to [[Special:Whatlinkshere]] + $out->addWikiMsg( 'linkstoimage-more', + $this->getContext()->getLanguage()->formatNum( $limit ), + $this->getTitle()->getPrefixedDBkey() + ); + } + + $out->addHTML( + Html::openElement( 'ul', + array( 'class' => 'mw-imagepage-linkstoimage' ) ) . "\n" + ); + $count = 0; + + // Sort the list by namespace:title + usort( $rows, array( $this, 'compare' ) ); + + // Create links for every element + $currentCount = 0; + foreach ( $rows as $element ) { + $currentCount++; + if ( $currentCount > $limit ) { + break; + } + + $query = array(); + # Add a redirect=no to make redirect pages reachable + if ( isset( $redirects[$element->page_title] ) ) { + $query['redirect'] = 'no'; + } + $link = Linker::linkKnown( + Title::makeTitle( $element->page_namespace, $element->page_title ), + null, array(), $query + ); + if ( !isset( $redirects[$element->page_title] ) ) { + # No redirects + $liContents = $link; + } elseif ( count( $redirects[$element->page_title] ) === 0 ) { + # Redirect without usages + $liContents = wfMessage( 'linkstoimage-redirect' )->rawParams( $link, '' )->parse(); + } else { + # Redirect with usages + $li = ''; + foreach ( $redirects[$element->page_title] as $row ) { + $currentCount++; + if ( $currentCount > $limit ) { + break; + } + + $link2 = Linker::linkKnown( Title::makeTitle( $row->page_namespace, $row->page_title ) ); + $li .= Html::rawElement( + 'li', + array( 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ), + $link2 + ) . "\n"; + } + + $ul = Html::rawElement( + 'ul', + array( 'class' => 'mw-imagepage-redirectstofile' ), + $li + ) . "\n"; + $liContents = wfMessage( 'linkstoimage-redirect' )->rawParams( + $link, $ul )->parse(); + } + $out->addHTML( Html::rawElement( + 'li', + array( 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ), + $liContents + ) . "\n" + ); + + }; + $out->addHTML( Html::closeElement( 'ul' ) . "\n" ); + $res->free(); + + // Add a links to [[Special:Whatlinkshere]] + if ( $count > $limit ) { + $out->addWikiMsg( 'morelinkstoimage', $this->getTitle()->getPrefixedDBkey() ); + } + $out->addHTML( Html::closeElement( 'div' ) . "\n" ); + } + + protected function imageDupes() { + $this->loadFile(); + $out = $this->getContext()->getOutput(); + + $dupes = $this->mPage->getDuplicates(); + if ( count( $dupes ) == 0 ) { + return; + } + + $out->addHTML( "<div id='mw-imagepage-section-duplicates'>\n" ); + $out->addWikiMsg( 'duplicatesoffile', + $this->getContext()->getLanguage()->formatNum( count( $dupes ) ), $this->getTitle()->getDBkey() + ); + $out->addHTML( "<ul class='mw-imagepage-duplicates'>\n" ); + + /** + * @var $file File + */ + foreach ( $dupes as $file ) { + $fromSrc = ''; + if ( $file->isLocal() ) { + $link = Linker::linkKnown( $file->getTitle() ); + } else { + $link = Linker::makeExternalLink( $file->getDescriptionUrl(), + $file->getTitle()->getPrefixedText() ); + $fromSrc = wfMessage( 'shared-repo-from', $file->getRepo()->getDisplayName() )->text(); + } + $out->addHTML( "<li>{$link} {$fromSrc}</li>\n" ); + } + $out->addHTML( "</ul></div>\n" ); + } + + /** + * Delete the file, or an earlier version of it + */ + public function delete() { + $file = $this->mPage->getFile(); + if ( !$file->exists() || !$file->isLocal() || $file->getRedirected() ) { + // Standard article deletion + parent::delete(); + return; + } + + $deleter = new FileDeleteForm( $file ); + $deleter->execute(); + } + + /** + * Display an error with a wikitext description + * + * @param string $description + */ + function showError( $description ) { + $out = $this->getContext()->getOutput(); + $out->setPageTitle( wfMessage( 'internalerror' ) ); + $out->setRobotPolicy( 'noindex,nofollow' ); + $out->setArticleRelated( false ); + $out->enableClientCache( false ); + $out->addWikiText( $description ); + } + + /** + * Callback for usort() to do link sorts by (namespace, title) + * Function copied from Title::compare() + * + * @param object $a Object page to compare with + * @param object $b Object page to compare with + * @return int Result of string comparison, or namespace comparison + */ + protected function compare( $a, $b ) { + if ( $a->page_namespace == $b->page_namespace ) { + return strcmp( $a->page_title, $b->page_title ); + } else { + return $a->page_namespace - $b->page_namespace; + } + } + + /** + * Returns the corresponding $wgImageLimits entry for the selected user option + * + * @param User $user + * @param string $optionName Name of a option to check, typically imagesize or thumbsize + * @return array + * @since 1.21 + */ + public function getImageLimitsFromOption( $user, $optionName ) { + global $wgImageLimits; + + $option = $user->getIntOption( $optionName ); + if ( !isset( $wgImageLimits[$option] ) ) { + $option = User::getDefaultOption( $optionName ); + } + + // The user offset might still be incorrect, specially if + // $wgImageLimits got changed (see bug #8858). + if ( !isset( $wgImageLimits[$option] ) ) { + // Default to the first offset in $wgImageLimits + $option = 0; + } + + return isset( $wgImageLimits[$option] ) + ? $wgImageLimits[$option] + : array( 800, 600 ); // if nothing is set, fallback to a hardcoded default + } + + /** + * Output a drop-down box for language options for the file + * + * @param array $langChoices Array of string language codes + * @param string $curLang Language code file is being viewed in. + * @param string $defaultLang Language code that image is rendered in by default + * @return string HTML to insert underneath image. + */ + protected function doRenderLangOpt( array $langChoices, $curLang, $defaultLang ) { + global $wgScript; + sort( $langChoices ); + $curLang = wfBCP47( $curLang ); + $defaultLang = wfBCP47( $defaultLang ); + $opts = ''; + $haveCurrentLang = false; + $haveDefaultLang = false; + + // We make a list of all the language choices in the file. + // Additionally if the default language to render this file + // is not included as being in this file (for example, in svgs + // usually the fallback content is the english content) also + // include a choice for that. Last of all, if we're viewing + // the file in a language not on the list, add it as a choice. + foreach ( $langChoices as $lang ) { + $code = wfBCP47( $lang ); + $name = Language::fetchLanguageName( $code, $this->getContext()->getLanguage()->getCode() ); + if ( $name !== '' ) { + $display = wfMessage( 'img-lang-opt', $code, $name )->text(); + } else { + $display = $code; + } + $opts .= "\n" . Xml::option( $display, $code, $curLang === $code ); + if ( $curLang === $code ) { + $haveCurrentLang = true; + } + if ( $defaultLang === $code ) { + $haveDefaultLang = true; + } + } + if ( !$haveDefaultLang ) { + // Its hard to know if the content is really in the default language, or + // if its just unmarked content that could be in any language. + $opts = Xml::option( + wfMessage( 'img-lang-default' )->text(), + $defaultLang, + $defaultLang === $curLang + ) . $opts; + } + if ( !$haveCurrentLang && $defaultLang !== $curLang ) { + $name = Language::fetchLanguageName( $curLang, $this->getContext()->getLanguage()->getCode() ); + if ( $name !== '' ) { + $display = wfMessage( 'img-lang-opt', $curLang, $name )->text(); + } else { + $display = $curLang; + } + $opts = Xml::option( $display, $curLang, true ) . $opts; + } + + $select = Html::rawElement( + 'select', + array( 'id' => 'mw-imglangselector', 'name' => 'lang' ), + $opts + ); + $submit = Xml::submitButton( wfMessage( 'img-lang-go' )->text() ); + + $formContents = wfMessage( 'img-lang-info' )->rawParams( $select, $submit )->parse() + . Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ); + + $langSelectLine = Html::rawElement( 'div', array( 'id' => 'mw-imglangselector-line' ), + Html::rawElement( 'form', array( 'action' => $wgScript ), $formContents ) + ); + return $langSelectLine; + } + + /** + * Get the width and height to display image at. + * + * @note This method assumes that it is only called if one + * of the dimensions are bigger than the max, or if the + * image is vectorized. + * + * @param int $maxWidth Max width to display at + * @param int $maxHeight Max height to display at + * @param int $width Actual width of the image + * @param int $height Actual height of the image + * @throws MWException + * @return array Array (width, height) + */ + protected function getDisplayWidthHeight( $maxWidth, $maxHeight, $width, $height ) { + if ( !$maxWidth || !$maxHeight ) { + // should never happen + throw new MWException( 'Using a choice from $wgImageLimits that is 0x0' ); + } + + if ( !$width || !$height ) { + return array( 0, 0 ); + } + + # Calculate the thumbnail size. + if ( $width <= $maxWidth && $height <= $maxHeight ) { + // Vectorized image, do nothing. + } elseif ( $width / $height >= $maxWidth / $maxHeight ) { + # The limiting factor is the width, not the height. + $height = round( $height * $maxWidth / $width ); + $width = $maxWidth; + # Note that $height <= $maxHeight now. + } else { + $newwidth = floor( $width * $maxHeight / $height ); + $height = round( $height * $newwidth / $width ); + $width = $newwidth; + # Note that $height <= $maxHeight now, but might not be identical + # because of rounding. + } + return array( $width, $height ); + } + + /** + * Get alternative thumbnail sizes. + * + * @note This will only list several alternatives if thumbnails are rendered on 404 + * @param int $origWidth Actual width of image + * @param int $origHeight Actual height of image + * @return array An array of [width, height] pairs. + */ + protected function getThumbSizes( $origWidth, $origHeight ) { + global $wgImageLimits; + if ( $this->displayImg->getRepo()->canTransformVia404() ) { + $thumbSizes = $wgImageLimits; + // Also include the full sized resolution in the list, so + // that users know they can get it. This will link to the + // original file asset if mustRender() === false. In the case + // that we mustRender, some users have indicated that they would + // find it useful to have the full size image in the rendered + // image format. + $thumbSizes[] = array( $origWidth, $origHeight ); + } else { + # Creating thumb links triggers thumbnail generation. + # Just generate the thumb for the current users prefs. + $thumbSizes = array( $this->getImageLimitsFromOption( $this->getContext()->getUser(), 'thumbsize' ) ); + if ( !$this->displayImg->mustRender() ) { + // We can safely include a link to the "full-size" preview, + // without actually rendering. + $thumbSizes[] = array( $origWidth, $origHeight ); + } + } + return $thumbSizes; + } + +} + +/** + * 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><td></td>' + . ( $this->current->isLocal() + && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<td></td>' : '' ) + . '<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 .= $this->msg( 'word-separator' )->escaped(); + $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; + wfRunHooks( '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 ) ) { + $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; + } + +} diff --git a/includes/page/WikiCategoryPage.php b/includes/page/WikiCategoryPage.php new file mode 100644 index 00000000..d3820016 --- /dev/null +++ b/includes/page/WikiCategoryPage.php @@ -0,0 +1,50 @@ +<?php +/** + * Special handling for category pages. + * + * 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 + */ + +/** + * Special handling for category pages + */ +class WikiCategoryPage extends WikiPage { + + /** + * Don't return a 404 for categories in use. + * In use defined as: either the actual page exists + * or the category currently has members. + * + * @return bool + */ + public function hasViewableContent() { + if ( parent::hasViewableContent() ) { + return true; + } else { + $cat = Category::newFromTitle( $this->mTitle ); + // If any of these are not 0, then has members + if ( $cat->getPageCount() + || $cat->getSubcatCount() + || $cat->getFileCount() + ) { + return true; + } + } + return false; + } +} diff --git a/includes/page/WikiFilePage.php b/includes/page/WikiFilePage.php new file mode 100644 index 00000000..bfcd4c31 --- /dev/null +++ b/includes/page/WikiFilePage.php @@ -0,0 +1,230 @@ +<?php +/** + * Special handling for file pages. + * + * 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 + */ + +/** + * Special handling for file pages + * + * @ingroup Media + */ +class WikiFilePage extends WikiPage { + /** + * @var File + */ + protected $mFile = false; // !< File object + protected $mRepo = null; // !< + protected $mFileLoaded = false; // !< + protected $mDupes = null; // !< + + public function __construct( $title ) { + parent::__construct( $title ); + $this->mDupes = null; + $this->mRepo = null; + } + + /** + * @param File $file + */ + public function setFile( $file ) { + $this->mFile = $file; + $this->mFileLoaded = true; + } + + /** + * @return bool + */ + protected function loadFile() { + if ( $this->mFileLoaded ) { + return true; + } + $this->mFileLoaded = true; + + $this->mFile = wfFindFile( $this->mTitle ); + if ( !$this->mFile ) { + $this->mFile = wfLocalFile( $this->mTitle ); // always a File + } + $this->mRepo = $this->mFile->getRepo(); + return true; + } + + /** + * @return mixed|null|Title + */ + public function getRedirectTarget() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::getRedirectTarget(); + } + // Foreign image page + $from = $this->mFile->getRedirected(); + $to = $this->mFile->getName(); + if ( $from == $to ) { + return null; + } + $this->mRedirectTarget = Title::makeTitle( NS_FILE, $to ); + return $this->mRedirectTarget; + } + + /** + * @return bool|mixed|Title + */ + public function followRedirect() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::followRedirect(); + } + $from = $this->mFile->getRedirected(); + $to = $this->mFile->getName(); + if ( $from == $to ) { + return false; + } + return Title::makeTitle( NS_FILE, $to ); + } + + /** + * @return bool + */ + public function isRedirect() { + $this->loadFile(); + if ( $this->mFile->isLocal() ) { + return parent::isRedirect(); + } + + return (bool)$this->mFile->getRedirected(); + } + + /** + * @return bool + */ + public function isLocal() { + $this->loadFile(); + return $this->mFile->isLocal(); + } + + /** + * @return bool|File + */ + public function getFile() { + $this->loadFile(); + return $this->mFile; + } + + /** + * @return array|null + */ + public function getDuplicates() { + $this->loadFile(); + if ( !is_null( $this->mDupes ) ) { + return $this->mDupes; + } + $hash = $this->mFile->getSha1(); + if ( !( $hash ) ) { + $this->mDupes = array(); + return $this->mDupes; + } + $dupes = RepoGroup::singleton()->findBySha1( $hash ); + // Remove duplicates with self and non matching file sizes + $self = $this->mFile->getRepoName() . ':' . $this->mFile->getName(); + $size = $this->mFile->getSize(); + + /** + * @var $file File + */ + foreach ( $dupes as $index => $file ) { + $key = $file->getRepoName() . ':' . $file->getName(); + if ( $key == $self ) { + unset( $dupes[$index] ); + } + if ( $file->getSize() != $size ) { + unset( $dupes[$index] ); + } + } + $this->mDupes = $dupes; + return $this->mDupes; + } + + /** + * Override handling of action=purge + * @return bool + */ + public function doPurge() { + $this->loadFile(); + if ( $this->mFile->exists() ) { + wfDebug( 'ImagePage::doPurge purging ' . $this->mFile->getName() . "\n" ); + $update = new HTMLCacheUpdate( $this->mTitle, 'imagelinks' ); + $update->doUpdate(); + $this->mFile->upgradeRow(); + $this->mFile->purgeCache( array( 'forThumbRefresh' => true ) ); + } else { + wfDebug( 'ImagePage::doPurge no image for ' + . $this->mFile->getName() . "; limiting purge to cache only\n" ); + // even if the file supposedly doesn't exist, force any cached information + // to be updated (in case the cached information is wrong) + $this->mFile->purgeCache( array( 'forThumbRefresh' => true ) ); + } + if ( $this->mRepo ) { + // Purge redirect cache + $this->mRepo->invalidateImageRedirect( $this->mTitle ); + } + return parent::doPurge(); + } + + /** + * Get the categories this file is a member of on the wiki where it was uploaded. + * For local files, this is the same as getCategories(). + * For foreign API files (InstantCommons), this is not supported currently. + * Results will include hidden categories. + * + * @return TitleArray|Title[] + * @since 1.23 + */ + public function getForeignCategories() { + $this->loadFile(); + $title = $this->mTitle; + $file = $this->mFile; + + if ( !$file instanceof LocalFile ) { + wfDebug( __CLASS__ . '::' . __METHOD__ . " is not supported for this file\n" ); + return TitleArray::newFromResult( new FakeResultWrapper( array() ) ); + } + + /** @var LocalRepo $repo */ + $repo = $file->getRepo(); + $dbr = $repo->getSlaveDB(); + + $res = $dbr->select( + array( 'page', 'categorylinks' ), + array( + 'page_title' => 'cl_to', + 'page_namespace' => NS_CATEGORY, + ), + array( + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey(), + ), + __METHOD__, + array(), + array( 'categorylinks' => array( 'INNER JOIN', 'page_id = cl_from' ) ) + ); + + return TitleArray::newFromResult( $res ); + } +} diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php new file mode 100644 index 00000000..9ade16e5 --- /dev/null +++ b/includes/page/WikiPage.php @@ -0,0 +1,3554 @@ +<?php +/** + * Base representation for a MediaWiki page. + * + * 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 + */ + +/** + * Abstract class for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage) + */ +interface Page { +} + +/** + * Class representing a MediaWiki article and history. + * + * Some fields are public only for backwards-compatibility. Use accessors. + * In the past, this class was part of Article.php and everything was public. + * + * @internal documentation reviewed 15 Mar 2010 + */ +class WikiPage implements Page, IDBAccessObject { + // Constants for $mDataLoadedFrom and related + + /** + * @var Title + */ + public $mTitle = null; + + /**@{{ + * @protected + */ + public $mDataLoaded = false; // !< Boolean + public $mIsRedirect = false; // !< Boolean + public $mLatest = false; // !< Integer (false means "not loaded") + /**@}}*/ + + /** @var stdclass Map of cache fields (text, parser output, ect) for a proposed/new edit */ + public $mPreparedEdit = false; + + /** + * @var int + */ + protected $mId = null; + + /** + * @var int One of the READ_* constants + */ + protected $mDataLoadedFrom = self::READ_NONE; + + /** + * @var Title + */ + protected $mRedirectTarget = null; + + /** + * @var Revision + */ + protected $mLastRevision = null; + + /** + * @var string Timestamp of the current revision or empty string if not loaded + */ + protected $mTimestamp = ''; + + /** + * @var string + */ + protected $mTouched = '19700101000000'; + + /** + * @var string + */ + protected $mLinksUpdated = '19700101000000'; + + /** + * @var int|null + */ + protected $mCounter = null; + + /** + * Constructor and clear the article + * @param Title $title Reference to a Title object. + */ + public function __construct( Title $title ) { + $this->mTitle = $title; + } + + /** + * Create a WikiPage object of the appropriate class for the given title. + * + * @param Title $title + * + * @throws MWException + * @return WikiPage Object of the appropriate type + */ + public static function factory( Title $title ) { + $ns = $title->getNamespace(); + + if ( $ns == NS_MEDIA ) { + throw new MWException( "NS_MEDIA is a virtual namespace; use NS_FILE." ); + } elseif ( $ns < 0 ) { + throw new MWException( "Invalid or virtual namespace $ns given." ); + } + + switch ( $ns ) { + case NS_FILE: + $page = new WikiFilePage( $title ); + break; + case NS_CATEGORY: + $page = new WikiCategoryPage( $title ); + break; + default: + $page = new WikiPage( $title ); + } + + return $page; + } + + /** + * Constructor from a page id + * + * @param int $id Article ID to load + * @param string|int $from One of the following values: + * - "fromdb" or WikiPage::READ_NORMAL to select from a slave database + * - "fromdbmaster" or WikiPage::READ_LATEST to select from the master database + * + * @return WikiPage|null + */ + public static function newFromID( $id, $from = 'fromdb' ) { + // page id's are never 0 or negative, see bug 61166 + if ( $id < 1 ) { + return null; + } + + $from = self::convertSelectType( $from ); + $db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_SLAVE ); + $row = $db->selectRow( 'page', self::selectFields(), array( 'page_id' => $id ), __METHOD__ ); + if ( !$row ) { + return null; + } + return self::newFromRow( $row, $from ); + } + + /** + * Constructor from a database row + * + * @since 1.20 + * @param object $row Database row containing at least fields returned by selectFields(). + * @param string|int $from Source of $data: + * - "fromdb" or WikiPage::READ_NORMAL: from a slave DB + * - "fromdbmaster" or WikiPage::READ_LATEST: from the master DB + * - "forupdate" or WikiPage::READ_LOCKING: from the master DB using SELECT FOR UPDATE + * @return WikiPage + */ + public static function newFromRow( $row, $from = 'fromdb' ) { + $page = self::factory( Title::newFromRow( $row ) ); + $page->loadFromRow( $row, $from ); + return $page; + } + + /** + * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants. + * + * @param object|string|int $type + * @return mixed + */ + private static function convertSelectType( $type ) { + switch ( $type ) { + case 'fromdb': + return self::READ_NORMAL; + case 'fromdbmaster': + return self::READ_LATEST; + case 'forupdate': + return self::READ_LOCKING; + default: + // It may already be an integer or whatever else + return $type; + } + } + + /** + * Returns overrides for action handlers. + * Classes listed here will be used instead of the default one when + * (and only when) $wgActions[$action] === true. This allows subclasses + * to override the default behavior. + * + * @todo Move this UI stuff somewhere else + * + * @return array + */ + public function getActionOverrides() { + $content_handler = $this->getContentHandler(); + return $content_handler->getActionOverrides(); + } + + /** + * Returns the ContentHandler instance to be used to deal with the content of this WikiPage. + * + * Shorthand for ContentHandler::getForModelID( $this->getContentModel() ); + * + * @return ContentHandler + * + * @since 1.21 + */ + public function getContentHandler() { + return ContentHandler::getForModelID( $this->getContentModel() ); + } + + /** + * Get the title object of the article + * @return Title Title object of this page + */ + public function getTitle() { + return $this->mTitle; + } + + /** + * Clear the object + * @return void + */ + public function clear() { + $this->mDataLoaded = false; + $this->mDataLoadedFrom = self::READ_NONE; + + $this->clearCacheFields(); + } + + /** + * Clear the object cache fields + * @return void + */ + protected function clearCacheFields() { + $this->mId = null; + $this->mCounter = null; + $this->mRedirectTarget = null; // Title object if set + $this->mLastRevision = null; // Latest revision + $this->mTouched = '19700101000000'; + $this->mLinksUpdated = '19700101000000'; + $this->mTimestamp = ''; + $this->mIsRedirect = false; + $this->mLatest = false; + // Bug 57026: do not clear mPreparedEdit since prepareTextForEdit() already checks + // the requested rev ID and content against the cached one for equality. For most + // content types, the output should not change during the lifetime of this cache. + // Clearing it can cause extra parses on edit for no reason. + } + + /** + * Clear the mPreparedEdit cache field, as may be needed by mutable content types + * @return void + * @since 1.23 + */ + public function clearPreparedEdit() { + $this->mPreparedEdit = false; + } + + /** + * Return the list of revision fields that should be selected to create + * a new page. + * + * @return array + */ + public static function selectFields() { + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; + + $fields = array( + 'page_id', + 'page_namespace', + 'page_title', + 'page_restrictions', + 'page_counter', + 'page_is_redirect', + 'page_is_new', + 'page_random', + 'page_touched', + 'page_links_updated', + 'page_latest', + 'page_len', + ); + + if ( $wgContentHandlerUseDB ) { + $fields[] = 'page_content_model'; + } + + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } + + return $fields; + } + + /** + * Fetch a page record with the given conditions + * @param DatabaseBase $dbr + * @param array $conditions + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + protected function pageData( $dbr, $conditions, $options = array() ) { + $fields = self::selectFields(); + + wfRunHooks( 'ArticlePageDataBefore', array( &$this, &$fields ) ); + + $row = $dbr->selectRow( 'page', $fields, $conditions, __METHOD__, $options ); + + wfRunHooks( 'ArticlePageDataAfter', array( &$this, &$row ) ); + + return $row; + } + + /** + * Fetch a page record matching the Title object's namespace and title + * using a sanitized title string + * + * @param DatabaseBase $dbr + * @param Title $title + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + public function pageDataFromTitle( $dbr, $title, $options = array() ) { + return $this->pageData( $dbr, array( + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() ), $options ); + } + + /** + * Fetch a page record matching the requested ID + * + * @param DatabaseBase $dbr + * @param int $id + * @param array $options + * @return object|bool Database result resource, or false on failure + */ + public function pageDataFromId( $dbr, $id, $options = array() ) { + return $this->pageData( $dbr, array( 'page_id' => $id ), $options ); + } + + /** + * Set the general counter, title etc data loaded from + * some source. + * + * @param object|string|int $from One of the following: + * - A DB query result object. + * - "fromdb" or WikiPage::READ_NORMAL to get from a slave DB. + * - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB. + * - "forupdate" or WikiPage::READ_LOCKING to get from the master DB + * using SELECT FOR UPDATE. + * + * @return void + */ + public function loadPageData( $from = 'fromdb' ) { + $from = self::convertSelectType( $from ); + if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) { + // We already have the data from the correct location, no need to load it twice. + return; + } + + if ( $from === self::READ_LOCKING ) { + $data = $this->pageDataFromTitle( wfGetDB( DB_MASTER ), $this->mTitle, array( 'FOR UPDATE' ) ); + } elseif ( $from === self::READ_LATEST ) { + $data = $this->pageDataFromTitle( wfGetDB( DB_MASTER ), $this->mTitle ); + } elseif ( $from === self::READ_NORMAL ) { + $data = $this->pageDataFromTitle( wfGetDB( DB_SLAVE ), $this->mTitle ); + // Use a "last rev inserted" timestamp key to diminish the issue of slave lag. + // Note that DB also stores the master position in the session and checks it. + $touched = $this->getCachedLastEditTime(); + if ( $touched ) { // key set + if ( !$data || $touched > wfTimestamp( TS_MW, $data->page_touched ) ) { + $from = self::READ_LATEST; + $data = $this->pageDataFromTitle( wfGetDB( DB_MASTER ), $this->mTitle ); + } + } + } else { + // No idea from where the caller got this data, assume slave database. + $data = $from; + $from = self::READ_NORMAL; + } + + $this->loadFromRow( $data, $from ); + } + + /** + * Load the object from a database row + * + * @since 1.20 + * @param object $data Database row containing at least fields returned by selectFields() + * @param string|int $from One of the following: + * - "fromdb" or WikiPage::READ_NORMAL if the data comes from a slave DB + * - "fromdbmaster" or WikiPage::READ_LATEST if the data comes from the master DB + * - "forupdate" or WikiPage::READ_LOCKING if the data comes from from + * the master DB using SELECT FOR UPDATE + */ + public function loadFromRow( $data, $from ) { + $lc = LinkCache::singleton(); + $lc->clearLink( $this->mTitle ); + + if ( $data ) { + $lc->addGoodLinkObjFromRow( $this->mTitle, $data ); + + $this->mTitle->loadFromRow( $data ); + + // Old-fashioned restrictions + $this->mTitle->loadRestrictions( $data->page_restrictions ); + + $this->mId = intval( $data->page_id ); + $this->mCounter = intval( $data->page_counter ); + $this->mTouched = wfTimestamp( TS_MW, $data->page_touched ); + $this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated ); + $this->mIsRedirect = intval( $data->page_is_redirect ); + $this->mLatest = intval( $data->page_latest ); + // Bug 37225: $latest may no longer match the cached latest Revision object. + // Double-check the ID of any cached latest Revision object for consistency. + if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) { + $this->mLastRevision = null; + $this->mTimestamp = ''; + } + } else { + $lc->addBadLinkObj( $this->mTitle ); + + $this->mTitle->loadFromRow( false ); + + $this->clearCacheFields(); + + $this->mId = 0; + } + + $this->mDataLoaded = true; + $this->mDataLoadedFrom = self::convertSelectType( $from ); + } + + /** + * @return int Page ID + */ + public function getId() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mId; + } + + /** + * @return bool Whether or not the page exists in the database + */ + public function exists() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mId > 0; + } + + /** + * Check if this page is something we're going to be showing + * some sort of sensible content for. If we return false, page + * views (plain action=view) will return an HTTP 404 response, + * so spiders and robots can know they're following a bad link. + * + * @return bool + */ + public function hasViewableContent() { + return $this->exists() || $this->mTitle->isAlwaysKnown(); + } + + /** + * @return int The view count for the page + */ + public function getCount() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + + return $this->mCounter; + } + + /** + * Tests if the article content represents a redirect + * + * @return bool + */ + public function isRedirect() { + $content = $this->getContent(); + if ( !$content ) { + return false; + } + + return $content->isRedirect(); + } + + /** + * Returns the page's content model id (see the CONTENT_MODEL_XXX constants). + * + * Will use the revisions actual content model if the page exists, + * and the page's default if the page doesn't exist yet. + * + * @return string + * + * @since 1.21 + */ + public function getContentModel() { + if ( $this->exists() ) { + // look at the revision's actual content model + $rev = $this->getRevision(); + + if ( $rev !== null ) { + return $rev->getContentModel(); + } else { + $title = $this->mTitle->getPrefixedDBkey(); + wfWarn( "Page $title exists but has no (visible) revisions!" ); + } + } + + // use the default model for this page + return $this->mTitle->getContentModel(); + } + + /** + * Loads page_touched and returns a value indicating if it should be used + * @return bool True if not a redirect + */ + public function checkTouched() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return !$this->mIsRedirect; + } + + /** + * Get the page_touched field + * @return string Containing GMT timestamp + */ + public function getTouched() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mTouched; + } + + /** + * Get the page_links_updated field + * @return string|null Containing GMT timestamp + */ + public function getLinksTimestamp() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return $this->mLinksUpdated; + } + + /** + * Get the page_latest field + * @return int The rev_id of current revision + */ + public function getLatest() { + if ( !$this->mDataLoaded ) { + $this->loadPageData(); + } + return (int)$this->mLatest; + } + + /** + * Get the Revision object of the oldest revision + * @return Revision|null + */ + public function getOldestRevision() { + wfProfileIn( __METHOD__ ); + + // Try using the slave database first, then try the master + $continue = 2; + $db = wfGetDB( DB_SLAVE ); + $revSelectFields = Revision::selectFields(); + + $row = null; + while ( $continue ) { + $row = $db->selectRow( + array( 'page', 'revision' ), + $revSelectFields, + array( + 'page_namespace' => $this->mTitle->getNamespace(), + 'page_title' => $this->mTitle->getDBkey(), + 'rev_page = page_id' + ), + __METHOD__, + array( + 'ORDER BY' => 'rev_timestamp ASC' + ) + ); + + if ( $row ) { + $continue = 0; + } else { + $db = wfGetDB( DB_MASTER ); + $continue--; + } + } + + wfProfileOut( __METHOD__ ); + return $row ? Revision::newFromRow( $row ) : null; + } + + /** + * Loads everything except the text + * This isn't necessary for all uses, so it's only done if needed. + */ + protected function loadLastEdit() { + if ( $this->mLastRevision !== null ) { + return; // already loaded + } + + $latest = $this->getLatest(); + if ( !$latest ) { + return; // page doesn't exist or is missing page_latest info + } + + // Bug 37225: if session S1 loads the page row FOR UPDATE, the result always includes the + // latest changes committed. This is true even within REPEATABLE-READ transactions, where + // S1 normally only sees changes committed before the first S1 SELECT. Thus we need S1 to + // also gets the revision row FOR UPDATE; otherwise, it may not find it since a page row + // UPDATE and revision row INSERT by S2 may have happened after the first S1 SELECT. + // http://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read. + $flags = ( $this->mDataLoadedFrom == self::READ_LOCKING ) ? Revision::READ_LOCKING : 0; + $revision = Revision::newFromPageId( $this->getId(), $latest, $flags ); + if ( $revision ) { // sanity + $this->setLastEdit( $revision ); + } + } + + /** + * Set the latest revision + * @param Revision $revision + */ + protected function setLastEdit( Revision $revision ) { + $this->mLastRevision = $revision; + $this->mTimestamp = $revision->getTimestamp(); + } + + /** + * Get the latest revision + * @return Revision|null + */ + public function getRevision() { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision; + } + return null; + } + + /** + * Get the content of the current revision. No side-effects... + * + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to $wgUser + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return Content|null The content of the current revision + * + * @since 1.21 + */ + public function getContent( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getContent( $audience, $user ); + } + return null; + } + + /** + * Get the text of the current revision. No side-effects... + * + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return string|bool The text of the current revision + * @deprecated since 1.21, getContent() should be used instead. + */ + public function getText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + ContentHandler::deprecated( __METHOD__, '1.21' ); + + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getText( $audience, $user ); + } + return false; + } + + /** + * Get the text of the current revision. No side-effects... + * + * @return string|bool The text of the current revision. False on failure + * @deprecated since 1.21, getContent() should be used instead. + */ + public function getRawText() { + ContentHandler::deprecated( __METHOD__, '1.21' ); + + return $this->getText( Revision::RAW ); + } + + /** + * @return string MW timestamp of last article revision + */ + public function getTimestamp() { + // Check if the field has been filled by WikiPage::setTimestamp() + if ( !$this->mTimestamp ) { + $this->loadLastEdit(); + } + + return wfTimestamp( TS_MW, $this->mTimestamp ); + } + + /** + * Set the page timestamp (use only to avoid DB queries) + * @param string $ts MW timestamp of last article revision + * @return void + */ + public function setTimestamp( $ts ) { + $this->mTimestamp = wfTimestamp( TS_MW, $ts ); + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return int User ID for the user that made the last article revision + */ + public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getUser( $audience, $user ); + } else { + return -1; + } + } + + /** + * Get the User object of the user who created the page + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return User|null + */ + public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $revision = $this->getOldestRevision(); + if ( $revision ) { + $userName = $revision->getUserText( $audience, $user ); + return User::newFromName( $userName, false ); + } else { + return null; + } + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return string Username of the user that made the last article revision + */ + public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getUserText( $audience, $user ); + } else { + return ''; + } + } + + /** + * @param int $audience One of: + * Revision::FOR_PUBLIC to be displayed to all users + * Revision::FOR_THIS_USER to be displayed to the given user + * Revision::RAW get the text regardless of permissions + * @param User $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return string Comment stored for the last article revision + */ + public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->getComment( $audience, $user ); + } else { + return ''; + } + } + + /** + * Returns true if last revision was marked as "minor edit" + * + * @return bool Minor edit indicator for the last article revision. + */ + public function getMinorEdit() { + $this->loadLastEdit(); + if ( $this->mLastRevision ) { + return $this->mLastRevision->isMinor(); + } else { + return false; + } + } + + /** + * Get the cached timestamp for the last time the page changed. + * This is only used to help handle slave lag by comparing to page_touched. + * @return string MW timestamp + */ + protected function getCachedLastEditTime() { + global $wgMemc; + $key = wfMemcKey( 'page-lastedit', md5( $this->mTitle->getPrefixedDBkey() ) ); + return $wgMemc->get( $key ); + } + + /** + * Set the cached timestamp for the last time the page changed. + * This is only used to help handle slave lag by comparing to page_touched. + * @param string $timestamp + * @return void + */ + public function setCachedLastEditTime( $timestamp ) { + global $wgMemc; + $key = wfMemcKey( 'page-lastedit', md5( $this->mTitle->getPrefixedDBkey() ) ); + $wgMemc->set( $key, wfTimestamp( TS_MW, $timestamp ), 60 * 15 ); + } + + /** + * Determine whether a page would be suitable for being counted as an + * article in the site_stats table based on the title & its content + * + * @param object|bool $editInfo (false): object returned by prepareTextForEdit(), + * if false, the current database state will be used + * @return bool + */ + public function isCountable( $editInfo = false ) { + global $wgArticleCountMethod; + + if ( !$this->mTitle->isContentPage() ) { + return false; + } + + if ( $editInfo ) { + $content = $editInfo->pstContent; + } else { + $content = $this->getContent(); + } + + if ( !$content || $content->isRedirect() ) { + return false; + } + + $hasLinks = null; + + if ( $wgArticleCountMethod === 'link' ) { + // nasty special case to avoid re-parsing to detect links + + if ( $editInfo ) { + // ParserOutput::getLinks() is a 2D array of page links, so + // to be really correct we would need to recurse in the array + // but the main array should only have items in it if there are + // links. + $hasLinks = (bool)count( $editInfo->output->getLinks() ); + } else { + $hasLinks = (bool)wfGetDB( DB_SLAVE )->selectField( 'pagelinks', 1, + array( 'pl_from' => $this->getId() ), __METHOD__ ); + } + } + + return $content->isCountable( $hasLinks ); + } + + /** + * If this page is a redirect, get its target + * + * The target will be fetched from the redirect table if possible. + * If this page doesn't have an entry there, call insertRedirect() + * @return Title|null Title object, or null if this page is not a redirect + */ + public function getRedirectTarget() { + if ( !$this->mTitle->isRedirect() ) { + return null; + } + + if ( $this->mRedirectTarget !== null ) { + return $this->mRedirectTarget; + } + + // Query the redirect table + $dbr = wfGetDB( DB_SLAVE ); + $row = $dbr->selectRow( 'redirect', + array( 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ), + array( 'rd_from' => $this->getId() ), + __METHOD__ + ); + + // rd_fragment and rd_interwiki were added later, populate them if empty + if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) { + $this->mRedirectTarget = Title::makeTitle( + $row->rd_namespace, $row->rd_title, + $row->rd_fragment, $row->rd_interwiki ); + return $this->mRedirectTarget; + } + + // This page doesn't have an entry in the redirect table + $this->mRedirectTarget = $this->insertRedirect(); + return $this->mRedirectTarget; + } + + /** + * Insert an entry for this page into the redirect table. + * + * Don't call this function directly unless you know what you're doing. + * @return Title|null Title object or null if not a redirect + */ + public function insertRedirect() { + // recurse through to only get the final target + $content = $this->getContent(); + $retval = $content ? $content->getUltimateRedirectTarget() : null; + if ( !$retval ) { + return null; + } + $this->insertRedirectEntry( $retval ); + return $retval; + } + + /** + * Insert or update the redirect table entry for this page to indicate + * it redirects to $rt . + * @param Title $rt Redirect target + */ + public function insertRedirectEntry( $rt ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->replace( 'redirect', array( 'rd_from' ), + array( + 'rd_from' => $this->getId(), + 'rd_namespace' => $rt->getNamespace(), + 'rd_title' => $rt->getDBkey(), + 'rd_fragment' => $rt->getFragment(), + 'rd_interwiki' => $rt->getInterwiki(), + ), + __METHOD__ + ); + } + + /** + * Get the Title object or URL this page redirects to + * + * @return bool|Title|string False, Title of in-wiki target, or string with URL + */ + public function followRedirect() { + return $this->getRedirectURL( $this->getRedirectTarget() ); + } + + /** + * Get the Title object or URL to use for a redirect. We use Title + * objects for same-wiki, non-special redirects and URLs for everything + * else. + * @param Title $rt Redirect target + * @return bool|Title|string False, Title object of local target, or string with URL + */ + public function getRedirectURL( $rt ) { + if ( !$rt ) { + return false; + } + + if ( $rt->isExternal() ) { + if ( $rt->isLocal() ) { + // Offsite wikis need an HTTP redirect. + // + // This can be hard to reverse and may produce loops, + // so they may be disabled in the site configuration. + $source = $this->mTitle->getFullURL( 'redirect=no' ); + return $rt->getFullURL( array( 'rdfrom' => $source ) ); + } else { + // External pages pages without "local" bit set are not valid + // redirect targets + return false; + } + } + + if ( $rt->isSpecialPage() ) { + // Gotta handle redirects to special pages differently: + // Fill the HTTP response "Location" header and ignore + // the rest of the page we're on. + // + // Some pages are not valid targets + if ( $rt->isValidRedirectTarget() ) { + return $rt->getFullURL(); + } else { + return false; + } + } + + return $rt; + } + + /** + * Get a list of users who have edited this article, not including the user who made + * the most recent revision, which you can get from $article->getUser() if you want it + * @return UserArrayFromResult + */ + public function getContributors() { + // @todo FIXME: This is expensive; cache this info somewhere. + + $dbr = wfGetDB( DB_SLAVE ); + + if ( $dbr->implicitGroupby() ) { + $realNameField = 'user_real_name'; + } else { + $realNameField = 'MIN(user_real_name) AS user_real_name'; + } + + $tables = array( 'revision', 'user' ); + + $fields = array( + 'user_id' => 'rev_user', + 'user_name' => 'rev_user_text', + $realNameField, + 'timestamp' => 'MAX(rev_timestamp)', + ); + + $conds = array( 'rev_page' => $this->getId() ); + + // The user who made the top revision gets credited as "this page was last edited by + // John, based on contributions by Tom, Dick and Harry", so don't include them twice. + $user = $this->getUser(); + if ( $user ) { + $conds[] = "rev_user != $user"; + } else { + $conds[] = "rev_user_text != {$dbr->addQuotes( $this->getUserText() )}"; + } + + $conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0"; // username hidden? + + $jconds = array( + 'user' => array( 'LEFT JOIN', 'rev_user = user_id' ), + ); + + $options = array( + 'GROUP BY' => array( 'rev_user', 'rev_user_text' ), + 'ORDER BY' => 'timestamp DESC', + ); + + $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds ); + return new UserArrayFromResult( $res ); + } + + /** + * Get the last N authors + * @param int $num Number of revisions to get + * @param int|string $revLatest The latest rev_id, selected from the master (optional) + * @return array Array of authors, duplicates not removed + */ + public function getLastNAuthors( $num, $revLatest = 0 ) { + wfProfileIn( __METHOD__ ); + // First try the slave + // If that doesn't have the latest revision, try the master + $continue = 2; + $db = wfGetDB( DB_SLAVE ); + + do { + $res = $db->select( array( 'page', 'revision' ), + array( 'rev_id', 'rev_user_text' ), + array( + 'page_namespace' => $this->mTitle->getNamespace(), + 'page_title' => $this->mTitle->getDBkey(), + 'rev_page = page_id' + ), __METHOD__, + array( + 'ORDER BY' => 'rev_timestamp DESC', + 'LIMIT' => $num + ) + ); + + if ( !$res ) { + wfProfileOut( __METHOD__ ); + return array(); + } + + $row = $db->fetchObject( $res ); + + if ( $continue == 2 && $revLatest && $row->rev_id != $revLatest ) { + $db = wfGetDB( DB_MASTER ); + $continue--; + } else { + $continue = 0; + } + } while ( $continue ); + + $authors = array( $row->rev_user_text ); + + foreach ( $res as $row ) { + $authors[] = $row->rev_user_text; + } + + wfProfileOut( __METHOD__ ); + return $authors; + } + + /** + * Should the parser cache be used? + * + * @param ParserOptions $parserOptions ParserOptions to check + * @param int $oldid + * @return bool + */ + public function isParserCacheUsed( ParserOptions $parserOptions, $oldid ) { + global $wgEnableParserCache; + + return $wgEnableParserCache + && $parserOptions->getStubThreshold() == 0 + && $this->exists() + && ( $oldid === null || $oldid === 0 || $oldid === $this->getLatest() ) + && $this->getContentHandler()->isParserCacheSupported(); + } + + /** + * Get a ParserOutput for the given ParserOptions and revision ID. + * The parser cache will be used if possible. + * + * @since 1.19 + * @param ParserOptions $parserOptions ParserOptions to use for the parse operation + * @param null|int $oldid Revision ID to get the text from, passing null or 0 will + * get the current revision (default value) + * + * @return ParserOutput|bool ParserOutput or false if the revision was not found + */ + public function getParserOutput( ParserOptions $parserOptions, $oldid = null ) { + wfProfileIn( __METHOD__ ); + + $useParserCache = $this->isParserCacheUsed( $parserOptions, $oldid ); + wfDebug( __METHOD__ . ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" ); + if ( $parserOptions->getStubThreshold() ) { + wfIncrStats( 'pcache_miss_stub' ); + } + + if ( $useParserCache ) { + $parserOutput = ParserCache::singleton()->get( $this, $parserOptions ); + if ( $parserOutput !== false ) { + wfProfileOut( __METHOD__ ); + return $parserOutput; + } + } + + if ( $oldid === null || $oldid === 0 ) { + $oldid = $this->getLatest(); + } + + $pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache ); + $pool->execute(); + + wfProfileOut( __METHOD__ ); + + return $pool->getParserOutput(); + } + + /** + * Do standard deferred updates after page view (existing or missing page) + * @param User $user The relevant user + * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed. + */ + public function doViewUpdates( User $user, $oldid = 0 ) { + global $wgDisableCounters; + if ( wfReadOnly() ) { + return; + } + + // Don't update page view counters on views from bot users (bug 14044) + if ( !$wgDisableCounters && !$user->isAllowed( 'bot' ) && $this->exists() ) { + DeferredUpdates::addUpdate( new ViewCountUpdate( $this->getId() ) ); + DeferredUpdates::addUpdate( new SiteStatsUpdate( 1, 0, 0 ) ); + } + + // Update newtalk / watchlist notification status + $user->clearNotification( $this->mTitle, $oldid ); + } + + /** + * Perform the actions of a page purging + * @return bool + */ + public function doPurge() { + global $wgUseSquid; + + if ( !wfRunHooks( 'ArticlePurge', array( &$this ) ) ) { + return false; + } + + // Invalidate the cache + $this->mTitle->invalidateCache(); + + if ( $wgUseSquid ) { + // Commit the transaction before the purge is sent + $dbw = wfGetDB( DB_MASTER ); + $dbw->commit( __METHOD__ ); + + // Send purge + $update = SquidUpdate::newSimplePurge( $this->mTitle ); + $update->doUpdate(); + } + + if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { + // @todo move this logic to MessageCache + + if ( $this->exists() ) { + // NOTE: use transclusion text for messages. + // This is consistent with MessageCache::getMsgFromNamespace() + + $content = $this->getContent(); + $text = $content === null ? null : $content->getWikitextForTransclusion(); + + if ( $text === null ) { + $text = false; + } + } else { + $text = false; + } + + MessageCache::singleton()->replace( $this->mTitle->getDBkey(), $text ); + } + return true; + } + + /** + * Insert a new empty page record for this article. + * This *must* be followed up by creating a revision + * and running $this->updateRevisionOn( ... ); + * or else the record will be left in a funky state. + * Best if all done inside a transaction. + * + * @param DatabaseBase $dbw + * @return int The newly created page_id key, or false if the title already existed + */ + public function insertOn( $dbw ) { + wfProfileIn( __METHOD__ ); + + $page_id = $dbw->nextSequenceValue( 'page_page_id_seq' ); + $dbw->insert( 'page', array( + 'page_id' => $page_id, + 'page_namespace' => $this->mTitle->getNamespace(), + 'page_title' => $this->mTitle->getDBkey(), + 'page_counter' => 0, + 'page_restrictions' => '', + 'page_is_redirect' => 0, // Will set this shortly... + 'page_is_new' => 1, + 'page_random' => wfRandom(), + 'page_touched' => $dbw->timestamp(), + 'page_latest' => 0, // Fill this in shortly... + 'page_len' => 0, // Fill this in shortly... + ), __METHOD__, 'IGNORE' ); + + $affected = $dbw->affectedRows(); + + if ( $affected ) { + $newid = $dbw->insertId(); + $this->mId = $newid; + $this->mTitle->resetArticleID( $newid ); + } + wfProfileOut( __METHOD__ ); + + return $affected ? $newid : false; + } + + /** + * Update the page record to point to a newly saved revision. + * + * @param DatabaseBase $dbw + * @param Revision $revision For ID number, and text used to set + * length and redirect status fields + * @param int $lastRevision If given, will not overwrite the page field + * when different from the currently set value. + * Giving 0 indicates the new page flag should be set on. + * @param bool $lastRevIsRedirect If given, will optimize adding and + * removing rows in redirect table. + * @return bool True on success, false on failure + */ + public function updateRevisionOn( $dbw, $revision, $lastRevision = null, + $lastRevIsRedirect = null + ) { + global $wgContentHandlerUseDB; + + wfProfileIn( __METHOD__ ); + + $content = $revision->getContent(); + $len = $content ? $content->getSize() : 0; + $rt = $content ? $content->getUltimateRedirectTarget() : null; + + $conditions = array( 'page_id' => $this->getId() ); + + if ( !is_null( $lastRevision ) ) { + // An extra check against threads stepping on each other + $conditions['page_latest'] = $lastRevision; + } + + $now = wfTimestampNow(); + $row = array( /* SET */ + 'page_latest' => $revision->getId(), + 'page_touched' => $dbw->timestamp( $now ), + 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0, + 'page_is_redirect' => $rt !== null ? 1 : 0, + 'page_len' => $len, + ); + + if ( $wgContentHandlerUseDB ) { + $row['page_content_model'] = $revision->getContentModel(); + } + + $dbw->update( 'page', + $row, + $conditions, + __METHOD__ ); + + $result = $dbw->affectedRows() > 0; + if ( $result ) { + $this->updateRedirectOn( $dbw, $rt, $lastRevIsRedirect ); + $this->setLastEdit( $revision ); + $this->setCachedLastEditTime( $now ); + $this->mLatest = $revision->getId(); + $this->mIsRedirect = (bool)$rt; + // Update the LinkCache. + LinkCache::singleton()->addGoodLinkObj( $this->getId(), $this->mTitle, $len, $this->mIsRedirect, + $this->mLatest, $revision->getContentModel() ); + } + + wfProfileOut( __METHOD__ ); + return $result; + } + + /** + * Add row to the redirect table if this is a redirect, remove otherwise. + * + * @param DatabaseBase $dbw + * @param Title $redirectTitle Title object pointing to the redirect target, + * or NULL if this is not a redirect + * @param null|bool $lastRevIsRedirect If given, will optimize adding and + * removing rows in redirect table. + * @return bool True on success, false on failure + * @private + */ + public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) { + // Always update redirects (target link might have changed) + // Update/Insert if we don't know if the last revision was a redirect or not + // Delete if changing from redirect to non-redirect + $isRedirect = !is_null( $redirectTitle ); + + if ( !$isRedirect && $lastRevIsRedirect === false ) { + return true; + } + + wfProfileIn( __METHOD__ ); + if ( $isRedirect ) { + $this->insertRedirectEntry( $redirectTitle ); + } else { + // This is not a redirect, remove row from redirect table + $where = array( 'rd_from' => $this->getId() ); + $dbw->delete( 'redirect', $where, __METHOD__ ); + } + + if ( $this->getTitle()->getNamespace() == NS_FILE ) { + RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() ); + } + wfProfileOut( __METHOD__ ); + + return ( $dbw->affectedRows() != 0 ); + } + + /** + * If the given revision is newer than the currently set page_latest, + * update the page record. Otherwise, do nothing. + * + * @deprecated since 1.24, use updateRevisionOn instead + * + * @param DatabaseBase $dbw + * @param Revision $revision + * @return bool + */ + public function updateIfNewerOn( $dbw, $revision ) { + wfProfileIn( __METHOD__ ); + + $row = $dbw->selectRow( + array( 'revision', 'page' ), + array( 'rev_id', 'rev_timestamp', 'page_is_redirect' ), + array( + 'page_id' => $this->getId(), + 'page_latest=rev_id' ), + __METHOD__ ); + + if ( $row ) { + if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) { + wfProfileOut( __METHOD__ ); + return false; + } + $prev = $row->rev_id; + $lastRevIsRedirect = (bool)$row->page_is_redirect; + } else { + // No or missing previous revision; mark the page as new + $prev = 0; + $lastRevIsRedirect = null; + } + + $ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect ); + + wfProfileOut( __METHOD__ ); + return $ret; + } + + /** + * Get the content that needs to be saved in order to undo all revisions + * between $undo and $undoafter. Revisions must belong to the same page, + * must exist and must not be deleted + * @param Revision $undo + * @param Revision $undoafter Must be an earlier revision than $undo + * @return mixed String on success, false on failure + * @since 1.21 + * Before we had the Content object, this was done in getUndoText + */ + public function getUndoContent( Revision $undo, Revision $undoafter = null ) { + $handler = $undo->getContentHandler(); + return $handler->getUndoContent( $this->getRevision(), $undo, $undoafter ); + } + + /** + * Get the text that needs to be saved in order to undo all revisions + * between $undo and $undoafter. Revisions must belong to the same page, + * must exist and must not be deleted + * @param Revision $undo + * @param Revision $undoafter Must be an earlier revision than $undo + * @return string|bool String on success, false on failure + * @deprecated since 1.21: use ContentHandler::getUndoContent() instead. + */ + public function getUndoText( Revision $undo, Revision $undoafter = null ) { + ContentHandler::deprecated( __METHOD__, '1.21' ); + + $this->loadLastEdit(); + + if ( $this->mLastRevision ) { + if ( is_null( $undoafter ) ) { + $undoafter = $undo->getPrevious(); + } + + $handler = $this->getContentHandler(); + $undone = $handler->getUndoContent( $this->mLastRevision, $undo, $undoafter ); + + if ( !$undone ) { + return false; + } else { + return ContentHandler::getContentText( $undone ); + } + } + + return false; + } + + /** + * @param string|number|null|bool $sectionId Section identifier as a number or string + * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page + * or 'new' for a new section. + * @param string $text New text of the section. + * @param string $sectionTitle New section's subject, only if $section is "new". + * @param string $edittime Revision timestamp or null to use the current revision. + * + * @throws MWException + * @return string New complete article text, or null if error. + * + * @deprecated since 1.21, use replaceSectionAtRev() instead + */ + public function replaceSection( $sectionId, $text, $sectionTitle = '', + $edittime = null + ) { + ContentHandler::deprecated( __METHOD__, '1.21' ); + + //NOTE: keep condition in sync with condition in replaceSectionContent! + if ( strval( $sectionId ) === '' ) { + // Whole-page edit; let the whole text through + return $text; + } + + if ( !$this->supportsSections() ) { + throw new MWException( "sections not supported for content model " . + $this->getContentHandler()->getModelID() ); + } + + // could even make section title, but that's not required. + $sectionContent = ContentHandler::makeContent( $text, $this->getTitle() ); + + $newContent = $this->replaceSectionContent( $sectionId, $sectionContent, $sectionTitle, + $edittime ); + + return ContentHandler::getContentText( $newContent ); + } + + /** + * Returns true if this page's content model supports sections. + * + * @return bool + * + * @todo The skin should check this and not offer section functionality if + * sections are not supported. + * @todo The EditPage should check this and not offer section functionality + * if sections are not supported. + */ + public function supportsSections() { + return $this->getContentHandler()->supportsSections(); + } + + /** + * @param string|number|null|bool $sectionId Section identifier as a number or string + * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page + * or 'new' for a new section. + * @param Content $sectionContent New content of the section. + * @param string $sectionTitle New section's subject, only if $section is "new". + * @param string $edittime Revision timestamp or null to use the current revision. + * + * @throws MWException + * @return Content New complete article content, or null if error. + * + * @since 1.21 + * @deprecated since 1.24, use replaceSectionAtRev instead + */ + public function replaceSectionContent( $sectionId, Content $sectionContent, $sectionTitle = '', + $edittime = null ) { + wfProfileIn( __METHOD__ ); + + $baseRevId = null; + if ( $edittime && $sectionId !== 'new' ) { + $dbw = wfGetDB( DB_MASTER ); + $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime ); + if ( $rev ) { + $baseRevId = $rev->getId(); + } + } + + wfProfileOut( __METHOD__ ); + return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId ); + } + + /** + * @param string|number|null|bool $sectionId Section identifier as a number or string + * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page + * or 'new' for a new section. + * @param Content $sectionContent New content of the section. + * @param string $sectionTitle New section's subject, only if $section is "new". + * @param int|null $baseRevId + * + * @throws MWException + * @return Content New complete article content, or null if error. + * + * @since 1.24 + */ + public function replaceSectionAtRev( $sectionId, Content $sectionContent, + $sectionTitle = '', $baseRevId = null + ) { + wfProfileIn( __METHOD__ ); + + if ( strval( $sectionId ) === '' ) { + // Whole-page edit; let the whole text through + $newContent = $sectionContent; + } else { + if ( !$this->supportsSections() ) { + wfProfileOut( __METHOD__ ); + throw new MWException( "sections not supported for content model " . + $this->getContentHandler()->getModelID() ); + } + + // Bug 30711: always use current version when adding a new section + if ( is_null( $baseRevId ) || $sectionId === 'new' ) { + $oldContent = $this->getContent(); + } else { + // TODO: try DB_SLAVE first + $dbw = wfGetDB( DB_MASTER ); + $rev = Revision::loadFromId( $dbw, $baseRevId ); + + if ( !$rev ) { + wfDebug( __METHOD__ . " asked for bogus section (page: " . + $this->getId() . "; section: $sectionId)\n" ); + wfProfileOut( __METHOD__ ); + return null; + } + + $oldContent = $rev->getContent(); + } + + if ( !$oldContent ) { + wfDebug( __METHOD__ . ": no page text\n" ); + wfProfileOut( __METHOD__ ); + return null; + } + + $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle ); + } + + wfProfileOut( __METHOD__ ); + return $newContent; + } + + /** + * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed. + * @param int $flags + * @return int Updated $flags + */ + public function checkFlags( $flags ) { + if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) { + if ( $this->exists() ) { + $flags |= EDIT_UPDATE; + } else { + $flags |= EDIT_NEW; + } + } + + return $flags; + } + + /** + * Change an existing article or create a new article. Updates RC and all necessary caches, + * optionally via the deferred update array. + * + * @param string $text New text + * @param string $summary Edit summary + * @param int $flags Bitfield: + * EDIT_NEW + * Article is known or assumed to be non-existent, create a new one + * EDIT_UPDATE + * Article is known or assumed to be pre-existing, update it + * EDIT_MINOR + * Mark this edit minor, if the user is allowed to do so + * EDIT_SUPPRESS_RC + * Do not log the change in recentchanges + * EDIT_FORCE_BOT + * Mark the edit a "bot" edit regardless of user rights + * EDIT_DEFER_UPDATES + * Defer some of the updates until the end of index.php + * EDIT_AUTOSUMMARY + * Fill in blank summaries with generated text where possible + * + * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the + * article will be detected. If EDIT_UPDATE is specified and the article + * doesn't exist, the function will return an edit-gone-missing error. If + * EDIT_NEW is specified and the article does exist, an edit-already-exists + * error will be returned. These two conditions are also possible with + * auto-detection due to MediaWiki's performance-optimised locking strategy. + * + * @param bool|int $baseRevId The revision ID this edit was based off, if any + * @param User $user The user doing the edit + * + * @throws MWException + * @return Status Possible errors: + * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't + * set the fatal flag of $status + * edit-gone-missing: In update mode, but the article didn't exist. + * edit-conflict: In update mode, the article changed unexpectedly. + * edit-no-change: Warning that the text was the same as before. + * edit-already-exists: In creation mode, but the article already exists. + * + * Extensions may define additional errors. + * + * $return->value will contain an associative array with members as follows: + * new: Boolean indicating if the function attempted to create a new article. + * revision: The revision object for the inserted revision, or null. + * + * Compatibility note: this function previously returned a boolean value + * indicating success/failure + * + * @deprecated since 1.21: use doEditContent() instead. + */ + public function doEdit( $text, $summary, $flags = 0, $baseRevId = false, $user = null ) { + ContentHandler::deprecated( __METHOD__, '1.21' ); + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + + return $this->doEditContent( $content, $summary, $flags, $baseRevId, $user ); + } + + /** + * Change an existing article or create a new article. Updates RC and all necessary caches, + * optionally via the deferred update array. + * + * @param Content $content New content + * @param string $summary Edit summary + * @param int $flags Bitfield: + * EDIT_NEW + * Article is known or assumed to be non-existent, create a new one + * EDIT_UPDATE + * Article is known or assumed to be pre-existing, update it + * EDIT_MINOR + * Mark this edit minor, if the user is allowed to do so + * EDIT_SUPPRESS_RC + * Do not log the change in recentchanges + * EDIT_FORCE_BOT + * Mark the edit a "bot" edit regardless of user rights + * EDIT_DEFER_UPDATES + * Defer some of the updates until the end of index.php + * EDIT_AUTOSUMMARY + * Fill in blank summaries with generated text where possible + * + * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the + * article will be detected. If EDIT_UPDATE is specified and the article + * doesn't exist, the function will return an edit-gone-missing error. If + * EDIT_NEW is specified and the article does exist, an edit-already-exists + * error will be returned. These two conditions are also possible with + * auto-detection due to MediaWiki's performance-optimised locking strategy. + * + * @param bool|int $baseRevId The revision ID this edit was based off, if any + * @param User $user The user doing the edit + * @param string $serialisation_format Format for storing the content in the + * database. + * + * @throws MWException + * @return Status Possible errors: + * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't + * set the fatal flag of $status. + * edit-gone-missing: In update mode, but the article didn't exist. + * edit-conflict: In update mode, the article changed unexpectedly. + * edit-no-change: Warning that the text was the same as before. + * edit-already-exists: In creation mode, but the article already exists. + * + * Extensions may define additional errors. + * + * $return->value will contain an associative array with members as follows: + * new: Boolean indicating if the function attempted to create a new article. + * revision: The revision object for the inserted revision, or null. + * + * @since 1.21 + */ + public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false, + User $user = null, $serialisation_format = null + ) { + global $wgUser, $wgUseAutomaticEditSummaries, $wgUseRCPatrol, $wgUseNPPatrol; + + // Low-level sanity check + if ( $this->mTitle->getText() === '' ) { + throw new MWException( 'Something is trying to edit an article with an empty title' ); + } + + wfProfileIn( __METHOD__ ); + + if ( !$content->getContentHandler()->canBeUsedOn( $this->getTitle() ) ) { + wfProfileOut( __METHOD__ ); + return Status::newFatal( 'content-not-allowed-here', + ContentHandler::getLocalizedName( $content->getModel() ), + $this->getTitle()->getPrefixedText() ); + } + + $user = is_null( $user ) ? $wgUser : $user; + $status = Status::newGood( array() ); + + // Load the data from the master database if needed. + // The caller may already loaded it from the master or even loaded it using + // SELECT FOR UPDATE, so do not override that using clear(). + $this->loadPageData( 'fromdbmaster' ); + + $flags = $this->checkFlags( $flags ); + + // handle hook + $hook_args = array( &$this, &$user, &$content, &$summary, + $flags & EDIT_MINOR, null, null, &$flags, &$status ); + + if ( !wfRunHooks( 'PageContentSave', $hook_args ) + || !ContentHandler::runLegacyHooks( 'ArticleSave', $hook_args ) ) { + + wfDebug( __METHOD__ . ": ArticleSave or ArticleSaveContent hook aborted save!\n" ); + + if ( $status->isOK() ) { + $status->fatal( 'edit-hook-aborted' ); + } + + wfProfileOut( __METHOD__ ); + return $status; + } + + // Silently ignore EDIT_MINOR if not allowed + $isminor = ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ); + $bot = $flags & EDIT_FORCE_BOT; + + $old_content = $this->getContent( Revision::RAW ); // current revision's content + + $oldsize = $old_content ? $old_content->getSize() : 0; + $oldid = $this->getLatest(); + $oldIsRedirect = $this->isRedirect(); + $oldcountable = $this->isCountable(); + + $handler = $content->getContentHandler(); + + // Provide autosummaries if one is not provided and autosummaries are enabled. + if ( $wgUseAutomaticEditSummaries && $flags & EDIT_AUTOSUMMARY && $summary == '' ) { + if ( !$old_content ) { + $old_content = null; + } + $summary = $handler->getAutosummary( $old_content, $content, $flags ); + } + + $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialisation_format ); + $serialized = $editInfo->pst; + + /** + * @var Content $content + */ + $content = $editInfo->pstContent; + $newsize = $content->getSize(); + + $dbw = wfGetDB( DB_MASTER ); + $now = wfTimestampNow(); + $this->mTimestamp = $now; + + if ( $flags & EDIT_UPDATE ) { + // Update article, but only if changed. + $status->value['new'] = false; + + if ( !$oldid ) { + // Article gone missing + wfDebug( __METHOD__ . ": EDIT_UPDATE specified but article doesn't exist\n" ); + $status->fatal( 'edit-gone-missing' ); + + wfProfileOut( __METHOD__ ); + return $status; + } elseif ( !$old_content ) { + // Sanity check for bug 37225 + wfProfileOut( __METHOD__ ); + throw new MWException( "Could not find text for current revision {$oldid}." ); + } + + $revision = new Revision( array( + 'page' => $this->getId(), + 'title' => $this->getTitle(), // for determining the default content model + 'comment' => $summary, + 'minor_edit' => $isminor, + 'text' => $serialized, + 'len' => $newsize, + 'parent_id' => $oldid, + 'user' => $user->getId(), + 'user_text' => $user->getName(), + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $serialisation_format, + ) ); // XXX: pass content object?! + + $changed = !$content->equals( $old_content ); + + if ( $changed ) { + if ( !$content->isValid() ) { + wfProfileOut( __METHOD__ ); + throw new MWException( "New content failed validity check!" ); + } + + $dbw->begin( __METHOD__ ); + try { + + $prepStatus = $content->prepareSave( $this, $flags, $baseRevId, $user ); + $status->merge( $prepStatus ); + + if ( !$status->isOK() ) { + $dbw->rollback( __METHOD__ ); + + wfProfileOut( __METHOD__ ); + return $status; + } + $revisionId = $revision->insertOn( $dbw ); + + // Update page + // + // We check for conflicts by comparing $oldid with the current latest revision ID. + $ok = $this->updateRevisionOn( $dbw, $revision, $oldid, $oldIsRedirect ); + + if ( !$ok ) { + // Belated edit conflict! Run away!! + $status->fatal( 'edit-conflict' ); + + $dbw->rollback( __METHOD__ ); + + wfProfileOut( __METHOD__ ); + return $status; + } + + wfRunHooks( 'NewRevisionFromEditComplete', array( $this, $revision, $baseRevId, $user ) ); + // Update recentchanges + if ( !( $flags & EDIT_SUPPRESS_RC ) ) { + // Mark as patrolled if the user can do so + $patrolled = $wgUseRCPatrol && !count( + $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) ); + // Add RC row to the DB + $rc = RecentChange::notifyEdit( $now, $this->mTitle, $isminor, $user, $summary, + $oldid, $this->getTimestamp(), $bot, '', $oldsize, $newsize, + $revisionId, $patrolled + ); + + // Log auto-patrolled edits + if ( $patrolled ) { + PatrolLog::record( $rc, true, $user ); + } + } + $user->incEditCount(); + } catch ( MWException $e ) { + $dbw->rollback( __METHOD__ ); + // Question: Would it perhaps be better if this method turned all + // exceptions into $status's? + throw $e; + } + $dbw->commit( __METHOD__ ); + } else { + // Bug 32948: revision ID must be set to page {{REVISIONID}} and + // related variables correctly + $revision->setId( $this->getLatest() ); + } + + // Update links tables, site stats, etc. + $this->doEditUpdates( + $revision, + $user, + array( + 'changed' => $changed, + 'oldcountable' => $oldcountable + ) + ); + + if ( !$changed ) { + $status->warning( 'edit-no-change' ); + $revision = null; + // Update page_touched, this is usually implicit in the page update + // Other cache updates are done in onArticleEdit() + $this->mTitle->invalidateCache(); + } + } else { + // Create new article + $status->value['new'] = true; + + $dbw->begin( __METHOD__ ); + try { + + $prepStatus = $content->prepareSave( $this, $flags, $baseRevId, $user ); + $status->merge( $prepStatus ); + + if ( !$status->isOK() ) { + $dbw->rollback( __METHOD__ ); + + wfProfileOut( __METHOD__ ); + return $status; + } + + $status->merge( $prepStatus ); + + // Add the page record; stake our claim on this title! + // This will return false if the article already exists + $newid = $this->insertOn( $dbw ); + + if ( $newid === false ) { + $dbw->rollback( __METHOD__ ); + $status->fatal( 'edit-already-exists' ); + + wfProfileOut( __METHOD__ ); + return $status; + } + + // Save the revision text... + $revision = new Revision( array( + 'page' => $newid, + 'title' => $this->getTitle(), // for determining the default content model + 'comment' => $summary, + 'minor_edit' => $isminor, + 'text' => $serialized, + 'len' => $newsize, + 'user' => $user->getId(), + 'user_text' => $user->getName(), + 'timestamp' => $now, + 'content_model' => $content->getModel(), + 'content_format' => $serialisation_format, + ) ); + $revisionId = $revision->insertOn( $dbw ); + + // Bug 37225: use accessor to get the text as Revision may trim it + $content = $revision->getContent(); // sanity; get normalized version + + if ( $content ) { + $newsize = $content->getSize(); + } + + // Update the page record with revision data + $this->updateRevisionOn( $dbw, $revision, 0 ); + + wfRunHooks( 'NewRevisionFromEditComplete', array( $this, $revision, false, $user ) ); + + // Update recentchanges + if ( !( $flags & EDIT_SUPPRESS_RC ) ) { + // Mark as patrolled if the user can do so + $patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) && !count( + $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) ); + // Add RC row to the DB + $rc = RecentChange::notifyNew( $now, $this->mTitle, $isminor, $user, $summary, $bot, + '', $newsize, $revisionId, $patrolled ); + + // Log auto-patrolled edits + if ( $patrolled ) { + PatrolLog::record( $rc, true, $user ); + } + } + $user->incEditCount(); + + } catch ( MWException $e ) { + $dbw->rollback( __METHOD__ ); + throw $e; + } + $dbw->commit( __METHOD__ ); + + // Update links, etc. + $this->doEditUpdates( $revision, $user, array( 'created' => true ) ); + + $hook_args = array( &$this, &$user, $content, $summary, + $flags & EDIT_MINOR, null, null, &$flags, $revision ); + + ContentHandler::runLegacyHooks( 'ArticleInsertComplete', $hook_args ); + wfRunHooks( 'PageContentInsertComplete', $hook_args ); + } + + // Do updates right now unless deferral was requested + if ( !( $flags & EDIT_DEFER_UPDATES ) ) { + DeferredUpdates::doUpdates(); + } + + // Return the new revision (or null) to the caller + $status->value['revision'] = $revision; + + $hook_args = array( &$this, &$user, $content, $summary, + $flags & EDIT_MINOR, null, null, &$flags, $revision, &$status, $baseRevId ); + + ContentHandler::runLegacyHooks( 'ArticleSaveComplete', $hook_args ); + wfRunHooks( 'PageContentSaveComplete', $hook_args ); + + // Promote user to any groups they meet the criteria for + $dbw->onTransactionIdle( function () use ( $user ) { + $user->addAutopromoteOnceGroups( 'onEdit' ); + } ); + + wfProfileOut( __METHOD__ ); + return $status; + } + + /** + * Get parser options suitable for rendering the primary article wikitext + * + * @see ContentHandler::makeParserOptions + * + * @param IContextSource|User|string $context One of the following: + * - IContextSource: Use the User and the Language of the provided + * context + * - User: Use the provided User object and $wgLang for the language, + * so use an IContextSource object if possible. + * - 'canonical': Canonical options (anonymous user with default + * preferences and content language). + * @return ParserOptions + */ + public function makeParserOptions( $context ) { + $options = $this->getContentHandler()->makeParserOptions( $context ); + + if ( $this->getTitle()->isConversionTable() ) { + // @todo ConversionTable should become a separate content model, so + // we don't need special cases like this one. + $options->disableContentConversion(); + } + + return $options; + } + + /** + * Prepare text which is about to be saved. + * Returns a stdclass with source, pst and output members + * + * @deprecated since 1.21: use prepareContentForEdit instead. + * @return object + */ + public function prepareTextForEdit( $text, $revid = null, User $user = null ) { + ContentHandler::deprecated( __METHOD__, '1.21' ); + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + return $this->prepareContentForEdit( $content, $revid, $user ); + } + + /** + * Prepare content which is about to be saved. + * Returns a stdclass with source, pst and output members + * + * @param Content $content + * @param int|null $revid + * @param User|null $user + * @param string|null $serialization_format + * + * @return bool|object + * + * @since 1.21 + */ + public function prepareContentForEdit( Content $content, $revid = null, User $user = null, + $serialization_format = null + ) { + global $wgContLang, $wgUser; + $user = is_null( $user ) ? $wgUser : $user; + //XXX: check $user->getId() here??? + + // Use a sane default for $serialization_format, see bug 57026 + if ( $serialization_format === null ) { + $serialization_format = $content->getContentHandler()->getDefaultFormat(); + } + + if ( $this->mPreparedEdit + && $this->mPreparedEdit->newContent + && $this->mPreparedEdit->newContent->equals( $content ) + && $this->mPreparedEdit->revid == $revid + && $this->mPreparedEdit->format == $serialization_format + // XXX: also check $user here? + ) { + // Already prepared + return $this->mPreparedEdit; + } + + $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang ); + wfRunHooks( 'ArticlePrepareTextForEdit', array( $this, $popts ) ); + + $edit = (object)array(); + $edit->revid = $revid; + $edit->timestamp = wfTimestampNow(); + + $edit->pstContent = $content ? $content->preSaveTransform( $this->mTitle, $user, $popts ) : null; + + $edit->format = $serialization_format; + $edit->popts = $this->makeParserOptions( 'canonical' ); + $edit->output = $edit->pstContent + ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts ) + : null; + + $edit->newContent = $content; + $edit->oldContent = $this->getContent( Revision::RAW ); + + // NOTE: B/C for hooks! don't use these fields! + $edit->newText = $edit->newContent ? ContentHandler::getContentText( $edit->newContent ) : ''; + $edit->oldText = $edit->oldContent ? ContentHandler::getContentText( $edit->oldContent ) : ''; + $edit->pst = $edit->pstContent ? $edit->pstContent->serialize( $serialization_format ) : ''; + + $this->mPreparedEdit = $edit; + return $edit; + } + + /** + * Do standard deferred updates after page edit. + * Update links tables, site stats, search index and message cache. + * Purges pages that include this page if the text was changed here. + * Every 100th edit, prune the recent changes table. + * + * @param Revision $revision + * @param User $user User object that did the revision + * @param array $options Array of options, following indexes are used: + * - changed: boolean, whether the revision changed the content (default true) + * - created: boolean, whether the revision created the page (default false) + * - oldcountable: boolean or null (default null): + * - boolean: whether the page was counted as an article before that + * revision, only used in changed is true and created is false + * - null: don't change the article count + */ + public function doEditUpdates( Revision $revision, User $user, array $options = array() ) { + global $wgEnableParserCache; + + wfProfileIn( __METHOD__ ); + + $options += array( 'changed' => true, 'created' => false, 'oldcountable' => null ); + $content = $revision->getContent(); + + // Parse the text + // Be careful not to do pre-save transform twice: $text is usually + // already pre-save transformed once. + if ( !$this->mPreparedEdit || $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) { + wfDebug( __METHOD__ . ": No prepared edit or vary-revision is set...\n" ); + $editInfo = $this->prepareContentForEdit( $content, $revision->getId(), $user ); + } else { + wfDebug( __METHOD__ . ": No vary-revision, using prepared edit...\n" ); + $editInfo = $this->mPreparedEdit; + } + + // Save it to the parser cache + if ( $wgEnableParserCache ) { + $parserCache = ParserCache::singleton(); + $parserCache->save( + $editInfo->output, $this, $editInfo->popts, $editInfo->timestamp, $editInfo->revid + ); + } + + // Update the links tables and other secondary data + if ( $content ) { + $recursive = $options['changed']; // bug 50785 + $updates = $content->getSecondaryDataUpdates( + $this->getTitle(), null, $recursive, $editInfo->output ); + DataUpdate::runUpdates( $updates ); + } + + wfRunHooks( 'ArticleEditUpdates', array( &$this, &$editInfo, $options['changed'] ) ); + + if ( wfRunHooks( 'ArticleEditUpdatesDeleteFromRecentchanges', array( &$this ) ) ) { + if ( 0 == mt_rand( 0, 99 ) ) { + // Flush old entries from the `recentchanges` table; we do this on + // random requests so as to avoid an increase in writes for no good reason + RecentChange::purgeExpiredChanges(); + } + } + + if ( !$this->exists() ) { + wfProfileOut( __METHOD__ ); + return; + } + + $id = $this->getId(); + $title = $this->mTitle->getPrefixedDBkey(); + $shortTitle = $this->mTitle->getDBkey(); + + if ( !$options['changed'] ) { + $good = 0; + } elseif ( $options['created'] ) { + $good = (int)$this->isCountable( $editInfo ); + } elseif ( $options['oldcountable'] !== null ) { + $good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable']; + } else { + $good = 0; + } + $edits = $options['changed'] ? 1 : 0; + $total = $options['created'] ? 1 : 0; + + DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, $edits, $good, $total ) ); + DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) ); + + // If this is another user's talk page, update newtalk. + // Don't do this if $options['changed'] = false (null-edits) nor if + // it's a minor edit and the user doesn't want notifications for those. + if ( $options['changed'] + && $this->mTitle->getNamespace() == NS_USER_TALK + && $shortTitle != $user->getTitleKey() + && !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) ) + ) { + $recipient = User::newFromName( $shortTitle, false ); + if ( !$recipient ) { + wfDebug( __METHOD__ . ": invalid username\n" ); + } else { + // Allow extensions to prevent user notification when a new message is added to their talk page + if ( wfRunHooks( 'ArticleEditUpdateNewTalk', array( &$this, $recipient ) ) ) { + if ( User::isIP( $shortTitle ) ) { + // An anonymous user + $recipient->setNewtalk( true, $revision ); + } elseif ( $recipient->isLoggedIn() ) { + $recipient->setNewtalk( true, $revision ); + } else { + wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" ); + } + } + } + } + + if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) { + // XXX: could skip pseudo-messages like js/css here, based on content model. + $msgtext = $content ? $content->getWikitextForTransclusion() : null; + if ( $msgtext === false || $msgtext === null ) { + $msgtext = ''; + } + + MessageCache::singleton()->replace( $shortTitle, $msgtext ); + } + + if ( $options['created'] ) { + self::onArticleCreate( $this->mTitle ); + } elseif ( $options['changed'] ) { // bug 50785 + self::onArticleEdit( $this->mTitle ); + } + + wfProfileOut( __METHOD__ ); + } + + /** + * Edit an article without doing all that other stuff + * The article must already exist; link tables etc + * are not updated, caches are not flushed. + * + * @param string $text Text submitted + * @param User $user The relevant user + * @param string $comment Comment submitted + * @param bool $minor Whereas it's a minor modification + * + * @deprecated since 1.21, use doEditContent() instead. + */ + public function doQuickEdit( $text, User $user, $comment = '', $minor = 0 ) { + ContentHandler::deprecated( __METHOD__, "1.21" ); + + $content = ContentHandler::makeContent( $text, $this->getTitle() ); + $this->doQuickEditContent( $content, $user, $comment, $minor ); + } + + /** + * Edit an article without doing all that other stuff + * The article must already exist; link tables etc + * are not updated, caches are not flushed. + * + * @param Content $content Content submitted + * @param User $user The relevant user + * @param string $comment Comment submitted + * @param bool $minor Whereas it's a minor modification + * @param string $serialisation_format Format for storing the content in the database + */ + public function doQuickEditContent( Content $content, User $user, $comment = '', $minor = false, + $serialisation_format = null + ) { + wfProfileIn( __METHOD__ ); + + $serialized = $content->serialize( $serialisation_format ); + + $dbw = wfGetDB( DB_MASTER ); + $revision = new Revision( array( + 'title' => $this->getTitle(), // for determining the default content model + 'page' => $this->getId(), + 'user_text' => $user->getName(), + 'user' => $user->getId(), + 'text' => $serialized, + 'length' => $content->getSize(), + 'comment' => $comment, + 'minor_edit' => $minor ? 1 : 0, + ) ); // XXX: set the content object? + $revision->insertOn( $dbw ); + $this->updateRevisionOn( $dbw, $revision ); + + wfRunHooks( 'NewRevisionFromEditComplete', array( $this, $revision, false, $user ) ); + + wfProfileOut( __METHOD__ ); + } + + /** + * Update the article's restriction field, and leave a log entry. + * This works for protection both existing and non-existing pages. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @param int &$cascade Set to false if cascading protection isn't allowed. + * @param string $reason + * @param User $user The user updating the restrictions + * @return Status + */ + public function doUpdateRestrictions( array $limit, array $expiry, + &$cascade, $reason, User $user + ) { + global $wgCascadingRestrictionLevels, $wgContLang; + + if ( wfReadOnly() ) { + return Status::newFatal( 'readonlytext', wfReadOnlyReason() ); + } + + $this->loadPageData( 'fromdbmaster' ); + $restrictionTypes = $this->mTitle->getRestrictionTypes(); + $id = $this->getId(); + + if ( !$cascade ) { + $cascade = false; + } + + // Take this opportunity to purge out expired restrictions + Title::purgeExpiredRestrictions(); + + // @todo FIXME: Same limitations as described in ProtectionForm.php (line 37); + // we expect a single selection, but the schema allows otherwise. + $isProtected = false; + $protect = false; + $changed = false; + + $dbw = wfGetDB( DB_MASTER ); + + foreach ( $restrictionTypes as $action ) { + if ( !isset( $expiry[$action] ) ) { + $expiry[$action] = $dbw->getInfinity(); + } + if ( !isset( $limit[$action] ) ) { + $limit[$action] = ''; + } elseif ( $limit[$action] != '' ) { + $protect = true; + } + + // Get current restrictions on $action + $current = implode( '', $this->mTitle->getRestrictions( $action ) ); + if ( $current != '' ) { + $isProtected = true; + } + + if ( $limit[$action] != $current ) { + $changed = true; + } elseif ( $limit[$action] != '' ) { + // Only check expiry change if the action is actually being + // protected, since expiry does nothing on an not-protected + // action. + if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) { + $changed = true; + } + } + } + + if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) { + $changed = true; + } + + // If nothing has changed, do nothing + if ( !$changed ) { + return Status::newGood(); + } + + if ( !$protect ) { // No protection at all means unprotection + $revCommentMsg = 'unprotectedarticle'; + $logAction = 'unprotect'; + } elseif ( $isProtected ) { + $revCommentMsg = 'modifiedarticleprotection'; + $logAction = 'modify'; + } else { + $revCommentMsg = 'protectedarticle'; + $logAction = 'protect'; + } + + // Truncate for whole multibyte characters + $reason = $wgContLang->truncate( $reason, 255 ); + + $logRelationsValues = array(); + $logRelationsField = null; + + if ( $id ) { // Protection of existing page + if ( !wfRunHooks( 'ArticleProtect', array( &$this, &$user, $limit, $reason ) ) ) { + return Status::newGood(); + } + + // Only certain restrictions can cascade... + $editrestriction = isset( $limit['edit'] ) + ? array( $limit['edit'] ) + : $this->mTitle->getRestrictions( 'edit' ); + foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) { + $editrestriction[$key] = 'editprotected'; // backwards compatibility + } + foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) { + $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility + } + + $cascadingRestrictionLevels = $wgCascadingRestrictionLevels; + foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) { + $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility + } + foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) { + $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility + } + + // The schema allows multiple restrictions + if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) { + $cascade = false; + } + + // insert null revision to identify the page protection change as edit summary + $latest = $this->getLatest(); + $nullRevision = $this->insertProtectNullRevision( + $revCommentMsg, + $limit, + $expiry, + $cascade, + $reason, + $user + ); + + if ( $nullRevision === null ) { + return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() ); + } + + $logRelationsField = 'pr_id'; + + // Update restrictions table + foreach ( $limit as $action => $restrictions ) { + $dbw->delete( + 'page_restrictions', + array( + 'pr_page' => $id, + 'pr_type' => $action + ), + __METHOD__ + ); + if ( $restrictions != '' ) { + $dbw->insert( + 'page_restrictions', + array( + 'pr_id' => $dbw->nextSequenceValue( 'page_restrictions_pr_id_seq' ), + 'pr_page' => $id, + 'pr_type' => $action, + 'pr_level' => $restrictions, + 'pr_cascade' => ( $cascade && $action == 'edit' ) ? 1 : 0, + 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] ) + ), + __METHOD__ + ); + $logRelationsValues[] = $dbw->insertId(); + } + } + + // Clear out legacy restriction fields + $dbw->update( + 'page', + array( 'page_restrictions' => '' ), + array( 'page_id' => $id ), + __METHOD__ + ); + + wfRunHooks( 'NewRevisionFromEditComplete', array( $this, $nullRevision, $latest, $user ) ); + wfRunHooks( 'ArticleProtectComplete', array( &$this, &$user, $limit, $reason ) ); + } else { // Protection of non-existing page (also known as "title protection") + // Cascade protection is meaningless in this case + $cascade = false; + + if ( $limit['create'] != '' ) { + $dbw->replace( 'protected_titles', + array( array( 'pt_namespace', 'pt_title' ) ), + array( + 'pt_namespace' => $this->mTitle->getNamespace(), + 'pt_title' => $this->mTitle->getDBkey(), + 'pt_create_perm' => $limit['create'], + 'pt_timestamp' => $dbw->timestamp(), + 'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ), + 'pt_user' => $user->getId(), + 'pt_reason' => $reason, + ), __METHOD__ + ); + } else { + $dbw->delete( 'protected_titles', + array( + 'pt_namespace' => $this->mTitle->getNamespace(), + 'pt_title' => $this->mTitle->getDBkey() + ), __METHOD__ + ); + } + } + + $this->mTitle->flushRestrictions(); + InfoAction::invalidateCache( $this->mTitle ); + + if ( $logAction == 'unprotect' ) { + $params = array(); + } else { + $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry ); + $params = array( $protectDescriptionLog, $cascade ? 'cascade' : '' ); + } + + // Update the protection log + $log = new LogPage( 'protect' ); + $logId = $log->addEntry( $logAction, $this->mTitle, $reason, $params, $user ); + if ( $logRelationsField !== null && count( $logRelationsValues ) ) { + $log->addRelations( $logRelationsField, $logRelationsValues, $logId ); + } + + return Status::newGood(); + } + + /** + * Insert a new null revision for this page. + * + * @param string $revCommentMsg Comment message key for the revision + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @param int $cascade Set to false if cascading protection isn't allowed. + * @param string $reason + * @param User|null $user + * @return Revision|null Null on error + */ + public function insertProtectNullRevision( $revCommentMsg, array $limit, + array $expiry, $cascade, $reason, $user = null + ) { + global $wgContLang; + $dbw = wfGetDB( DB_MASTER ); + + // Prepare a null revision to be added to the history + $editComment = $wgContLang->ucfirst( + wfMessage( + $revCommentMsg, + $this->mTitle->getPrefixedText() + )->inContentLanguage()->text() + ); + if ( $reason ) { + $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; + } + $protectDescription = $this->protectDescription( $limit, $expiry ); + if ( $protectDescription ) { + $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + $editComment .= wfMessage( 'parentheses' )->params( $protectDescription ) + ->inContentLanguage()->text(); + } + if ( $cascade ) { + $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + $editComment .= wfMessage( 'brackets' )->params( + wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text() + )->inContentLanguage()->text(); + } + + $nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user ); + if ( $nullRev ) { + $nullRev->insertOn( $dbw ); + + // Update page record and touch page + $oldLatest = $nullRev->getParentId(); + $this->updateRevisionOn( $dbw, $nullRev, $oldLatest ); + } + + return $nullRev; + } + + /** + * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid + * @return string + */ + protected function formatExpiry( $expiry ) { + global $wgContLang; + $dbr = wfGetDB( DB_SLAVE ); + + $encodedExpiry = $dbr->encodeExpiry( $expiry ); + if ( $encodedExpiry != 'infinity' ) { + return wfMessage( + 'protect-expiring', + $wgContLang->timeanddate( $expiry, false, false ), + $wgContLang->date( $expiry, false, false ), + $wgContLang->time( $expiry, false, false ) + )->inContentLanguage()->text(); + } else { + return wfMessage( 'protect-expiry-indefinite' ) + ->inContentLanguage()->text(); + } + } + + /** + * Builds the description to serve as comment for the edit. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @return string + */ + public function protectDescription( array $limit, array $expiry ) { + $protectDescription = ''; + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + # $action is one of $wgRestrictionTypes = array( 'create', 'edit', 'move', 'upload' ). + # All possible message keys are listed here for easier grepping: + # * restriction-create + # * restriction-edit + # * restriction-move + # * restriction-upload + $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text(); + # $restrictions is one of $wgRestrictionLevels = array( '', 'autoconfirmed', 'sysop' ), + # with '' filtered out. All possible message keys are listed below: + # * protect-level-autoconfirmed + # * protect-level-sysop + $restrictionsText = wfMessage( 'protect-level-' . $restrictions )->inContentLanguage()->text(); + + $expiryText = $this->formatExpiry( $expiry[$action] ); + + if ( $protectDescription !== '' ) { + $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text(); + } + $protectDescription .= wfMessage( 'protect-summary-desc' ) + ->params( $actionText, $restrictionsText, $expiryText ) + ->inContentLanguage()->text(); + } + + return $protectDescription; + } + + /** + * Builds the description to serve as comment for the log entry. + * + * Some bots may parse IRC lines, which are generated from log entries which contain plain + * protect description text. Keep them in old format to avoid breaking compatibility. + * TODO: Fix protection log to store structured description and format it on-the-fly. + * + * @param array $limit Set of restriction keys + * @param array $expiry Per restriction type expiration + * @return string + */ + public function protectDescriptionLog( array $limit, array $expiry ) { + global $wgContLang; + + $protectDescriptionLog = ''; + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + $expiryText = $this->formatExpiry( $expiry[$action] ); + $protectDescriptionLog .= $wgContLang->getDirMark() . "[$action=$restrictions] ($expiryText)"; + } + + return trim( $protectDescriptionLog ); + } + + /** + * Take an array of page restrictions and flatten it to a string + * suitable for insertion into the page_restrictions field. + * + * @param string[] $limit + * + * @throws MWException + * @return string + */ + protected static function flattenRestrictions( $limit ) { + if ( !is_array( $limit ) ) { + throw new MWException( 'WikiPage::flattenRestrictions given non-array restriction set' ); + } + + $bits = array(); + ksort( $limit ); + + foreach ( array_filter( $limit ) as $action => $restrictions ) { + $bits[] = "$action=$restrictions"; + } + + return implode( ':', $bits ); + } + + /** + * Same as doDeleteArticleReal(), but returns a simple boolean. This is kept around for + * backwards compatibility, if you care about error reporting you should use + * doDeleteArticleReal() instead. + * + * Deletes the article with database consistency, writes logs, purges caches + * + * @param string $reason Delete reason for deletion log + * @param bool $suppress Suppress all revisions and log the deletion in + * the suppression log instead of the deletion log + * @param int $id Article ID + * @param bool $commit Defaults to true, triggers transaction end + * @param array &$error Array of errors to append to + * @param User $user The deleting user + * @return bool True if successful + */ + public function doDeleteArticle( + $reason, $suppress = false, $id = 0, $commit = true, &$error = '', User $user = null + ) { + $status = $this->doDeleteArticleReal( $reason, $suppress, $id, $commit, $error, $user ); + return $status->isGood(); + } + + /** + * Back-end article deletion + * Deletes the article with database consistency, writes logs, purges caches + * + * @since 1.19 + * + * @param string $reason Delete reason for deletion log + * @param bool $suppress Suppress all revisions and log the deletion in + * the suppression log instead of the deletion log + * @param int $id Article ID + * @param bool $commit Defaults to true, triggers transaction end + * @param array &$error Array of errors to append to + * @param User $user The deleting user + * @return Status Status object; if successful, $status->value is the log_id of the + * deletion log entry. If the page couldn't be deleted because it wasn't + * found, $status is a non-fatal 'cannotdelete' error + */ + public function doDeleteArticleReal( + $reason, $suppress = false, $id = 0, $commit = true, &$error = '', User $user = null + ) { + global $wgUser, $wgContentHandlerUseDB; + + wfDebug( __METHOD__ . "\n" ); + + $status = Status::newGood(); + + if ( $this->mTitle->getDBkey() === '' ) { + $status->error( 'cannotdelete', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); + return $status; + } + + $user = is_null( $user ) ? $wgUser : $user; + if ( !wfRunHooks( 'ArticleDelete', array( &$this, &$user, &$reason, &$error, &$status ) ) ) { + if ( $status->isOK() ) { + // Hook aborted but didn't set a fatal status + $status->fatal( 'delete-hook-aborted' ); + } + return $status; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin( __METHOD__ ); + + if ( $id == 0 ) { + $this->loadPageData( 'forupdate' ); + $id = $this->getID(); + if ( $id == 0 ) { + $dbw->rollback( __METHOD__ ); + $status->error( 'cannotdelete', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); + return $status; + } + } + + // we need to remember the old content so we can use it to generate all deletion updates. + $content = $this->getContent( Revision::RAW ); + + // Bitfields to further suppress the content + if ( $suppress ) { + $bitfield = 0; + // This should be 15... + $bitfield |= Revision::DELETED_TEXT; + $bitfield |= Revision::DELETED_COMMENT; + $bitfield |= Revision::DELETED_USER; + $bitfield |= Revision::DELETED_RESTRICTED; + } else { + $bitfield = 'rev_deleted'; + } + + // For now, shunt the revision data into the archive table. + // Text is *not* removed from the text table; bulk storage + // is left intact to avoid breaking block-compression or + // immutable storage schemes. + // + // For backwards compatibility, note that some older archive + // table entries will have ar_text and ar_flags fields still. + // + // In the future, we may keep revisions and mark them with + // the rev_deleted field, which is reserved for this purpose. + + $row = array( + 'ar_namespace' => 'page_namespace', + 'ar_title' => 'page_title', + 'ar_comment' => 'rev_comment', + 'ar_user' => 'rev_user', + 'ar_user_text' => 'rev_user_text', + 'ar_timestamp' => 'rev_timestamp', + 'ar_minor_edit' => 'rev_minor_edit', + 'ar_rev_id' => 'rev_id', + 'ar_parent_id' => 'rev_parent_id', + 'ar_text_id' => 'rev_text_id', + 'ar_text' => '\'\'', // Be explicit to appease + 'ar_flags' => '\'\'', // MySQL's "strict mode"... + 'ar_len' => 'rev_len', + 'ar_page_id' => 'page_id', + 'ar_deleted' => $bitfield, + 'ar_sha1' => 'rev_sha1', + ); + + if ( $wgContentHandlerUseDB ) { + $row['ar_content_model'] = 'rev_content_model'; + $row['ar_content_format'] = 'rev_content_format'; + } + + $dbw->insertSelect( 'archive', array( 'page', 'revision' ), + $row, + array( + 'page_id' => $id, + 'page_id = rev_page' + ), __METHOD__ + ); + + // Now that it's safely backed up, delete it + $dbw->delete( 'page', array( 'page_id' => $id ), __METHOD__ ); + $ok = ( $dbw->affectedRows() > 0 ); // $id could be laggy + + if ( !$ok ) { + $dbw->rollback( __METHOD__ ); + $status->error( 'cannotdelete', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); + return $status; + } + + if ( !$dbw->cascadingDeletes() ) { + $dbw->delete( 'revision', array( 'rev_page' => $id ), __METHOD__ ); + } + + // Clone the title, so we have the information we need when we log + $logTitle = clone $this->mTitle; + + // Log the deletion, if the page was suppressed, log it at Oversight instead + $logtype = $suppress ? 'suppress' : 'delete'; + + $logEntry = new ManualLogEntry( $logtype, 'delete' ); + $logEntry->setPerformer( $user ); + $logEntry->setTarget( $logTitle ); + $logEntry->setComment( $reason ); + $logid = $logEntry->insert(); + + $dbw->onTransactionPreCommitOrIdle( function () use ( $dbw, $logEntry, $logid ) { + // Bug 56776: avoid deadlocks (especially from FileDeleteForm) + $logEntry->publish( $logid ); + } ); + + if ( $commit ) { + $dbw->commit( __METHOD__ ); + } + + $this->doDeleteUpdates( $id, $content ); + + wfRunHooks( 'ArticleDeleteComplete', array( &$this, &$user, $reason, $id, $content, $logEntry ) ); + $status->value = $logid; + return $status; + } + + /** + * Do some database updates after deletion + * + * @param int $id The page_id value of the page being deleted + * @param Content $content Optional page content to be used when determining + * the required updates. This may be needed because $this->getContent() + * may already return null when the page proper was deleted. + */ + public function doDeleteUpdates( $id, Content $content = null ) { + // update site status + DeferredUpdates::addUpdate( new SiteStatsUpdate( 0, 1, - (int)$this->isCountable(), -1 ) ); + + // remove secondary indexes, etc + $updates = $this->getDeletionUpdates( $content ); + DataUpdate::runUpdates( $updates ); + + // Reparse any pages transcluding this page + LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'templatelinks' ); + + // Reparse any pages including this image + if ( $this->mTitle->getNamespace() == NS_FILE ) { + LinksUpdate::queueRecursiveJobsForTable( $this->mTitle, 'imagelinks' ); + } + + // Clear caches + WikiPage::onArticleDelete( $this->mTitle ); + + // Reset this object and the Title object + $this->loadFromRow( false, self::READ_LATEST ); + + // Search engine + DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) ); + } + + /** + * Roll back the most recent consecutive set of edits to a page + * from the same user; fails if there are no eligible edits to + * roll back to, e.g. user is the sole contributor. This function + * performs permissions checks on $user, then calls commitRollback() + * to do the dirty work + * + * @todo Separate the business/permission stuff out from backend code + * + * @param string $fromP Name of the user whose edits to rollback. + * @param string $summary Custom summary. Set to default summary if empty. + * @param string $token Rollback token. + * @param bool $bot If true, mark all reverted edits as bot. + * + * @param array $resultDetails Array contains result-specific array of additional values + * 'alreadyrolled' : 'current' (rev) + * success : 'summary' (str), 'current' (rev), 'target' (rev) + * + * @param User $user The user performing the rollback + * @return array Array of errors, each error formatted as + * array(messagekey, param1, param2, ...). + * On success, the array is empty. This array can also be passed to + * OutputPage::showPermissionsErrorPage(). + */ + public function doRollback( + $fromP, $summary, $token, $bot, &$resultDetails, User $user + ) { + $resultDetails = null; + + // Check permissions + $editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user ); + $rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user ); + $errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) ); + + if ( !$user->matchEditToken( $token, array( $this->mTitle->getPrefixedText(), $fromP ) ) ) { + $errors[] = array( 'sessionfailure' ); + } + + if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) { + $errors[] = array( 'actionthrottledtext' ); + } + + // If there were errors, bail out now + if ( !empty( $errors ) ) { + return $errors; + } + + return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user ); + } + + /** + * Backend implementation of doRollback(), please refer there for parameter + * and return value documentation + * + * NOTE: This function does NOT check ANY permissions, it just commits the + * rollback to the DB. Therefore, you should only call this function direct- + * ly if you want to use custom permissions checks. If you don't, use + * doRollback() instead. + * @param string $fromP Name of the user whose edits to rollback. + * @param string $summary Custom summary. Set to default summary if empty. + * @param bool $bot If true, mark all reverted edits as bot. + * + * @param array $resultDetails Contains result-specific array of additional values + * @param User $guser The user performing the rollback + * @return array + */ + public function commitRollback( $fromP, $summary, $bot, &$resultDetails, User $guser ) { + global $wgUseRCPatrol, $wgContLang; + + $dbw = wfGetDB( DB_MASTER ); + + if ( wfReadOnly() ) { + return array( array( 'readonlytext' ) ); + } + + // Get the last editor + $current = $this->getRevision(); + if ( is_null( $current ) ) { + // Something wrong... no page? + return array( array( 'notanarticle' ) ); + } + + $from = str_replace( '_', ' ', $fromP ); + // User name given should match up with the top revision. + // If the user was deleted then $from should be empty. + if ( $from != $current->getUserText() ) { + $resultDetails = array( 'current' => $current ); + return array( array( 'alreadyrolled', + htmlspecialchars( $this->mTitle->getPrefixedText() ), + htmlspecialchars( $fromP ), + htmlspecialchars( $current->getUserText() ) + ) ); + } + + // Get the last edit not by this guy... + // Note: these may not be public values + $user = intval( $current->getRawUser() ); + $user_text = $dbw->addQuotes( $current->getRawUserText() ); + $s = $dbw->selectRow( 'revision', + array( 'rev_id', 'rev_timestamp', 'rev_deleted' ), + array( 'rev_page' => $current->getPage(), + "rev_user != {$user} OR rev_user_text != {$user_text}" + ), __METHOD__, + array( 'USE INDEX' => 'page_timestamp', + 'ORDER BY' => 'rev_timestamp DESC' ) + ); + if ( $s === false ) { + // No one else ever edited this page + return array( array( 'cantrollback' ) ); + } elseif ( $s->rev_deleted & Revision::DELETED_TEXT + || $s->rev_deleted & Revision::DELETED_USER + ) { + // Only admins can see this text + return array( array( 'notvisiblerev' ) ); + } + + // Set patrolling and bot flag on the edits, which gets rollbacked. + // This is done before the rollback edit to have patrolling also on failure (bug 62157). + $set = array(); + if ( $bot && $guser->isAllowed( 'markbotedits' ) ) { + // Mark all reverted edits as bot + $set['rc_bot'] = 1; + } + + if ( $wgUseRCPatrol ) { + // Mark all reverted edits as patrolled + $set['rc_patrolled'] = 1; + } + + if ( count( $set ) ) { + $dbw->update( 'recentchanges', $set, + array( /* WHERE */ + 'rc_cur_id' => $current->getPage(), + 'rc_user_text' => $current->getUserText(), + 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ), + ), __METHOD__ + ); + } + + // Generate the edit summary if necessary + $target = Revision::newFromId( $s->rev_id ); + if ( empty( $summary ) ) { + if ( $from == '' ) { // no public user name + $summary = wfMessage( 'revertpage-nouser' ); + } else { + $summary = wfMessage( 'revertpage' ); + } + } + + // Allow the custom summary to use the same args as the default message + $args = array( + $target->getUserText(), $from, $s->rev_id, + $wgContLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ), + $current->getId(), $wgContLang->timeanddate( $current->getTimestamp() ) + ); + if ( $summary instanceof Message ) { + $summary = $summary->params( $args )->inContentLanguage()->text(); + } else { + $summary = wfMsgReplaceArgs( $summary, $args ); + } + + // Trim spaces on user supplied text + $summary = trim( $summary ); + + // Truncate for whole multibyte characters. + $summary = $wgContLang->truncate( $summary, 255 ); + + // Save + $flags = EDIT_UPDATE; + + if ( $guser->isAllowed( 'minoredit' ) ) { + $flags |= EDIT_MINOR; + } + + if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) { + $flags |= EDIT_FORCE_BOT; + } + + // Actually store the edit + $status = $this->doEditContent( + $target->getContent(), + $summary, + $flags, + $target->getId(), + $guser + ); + + if ( !$status->isOK() ) { + return $status->getErrorsArray(); + } + + // raise error, when the edit is an edit without a new version + if ( empty( $status->value['revision'] ) ) { + $resultDetails = array( 'current' => $current ); + return array( array( 'alreadyrolled', + htmlspecialchars( $this->mTitle->getPrefixedText() ), + htmlspecialchars( $fromP ), + htmlspecialchars( $current->getUserText() ) + ) ); + } + + $revId = $status->value['revision']->getId(); + + wfRunHooks( 'ArticleRollbackComplete', array( $this, $guser, $target, $current ) ); + + $resultDetails = array( + 'summary' => $summary, + 'current' => $current, + 'target' => $target, + 'newid' => $revId + ); + + return array(); + } + + /** + * The onArticle*() functions are supposed to be a kind of hooks + * which should be called whenever any of the specified actions + * are done. + * + * This is a good place to put code to clear caches, for instance. + * + * This is called on page move and undelete, as well as edit + * + * @param Title $title + */ + public static function onArticleCreate( $title ) { + // Update existence markers on article/talk tabs... + if ( $title->isTalkPage() ) { + $other = $title->getSubjectPage(); + } else { + $other = $title->getTalkPage(); + } + + $other->invalidateCache(); + $other->purgeSquid(); + + $title->touchLinks(); + $title->purgeSquid(); + $title->deleteTitleProtection(); + } + + /** + * Clears caches when article is deleted + * + * @param Title $title + */ + public static function onArticleDelete( $title ) { + // Update existence markers on article/talk tabs... + if ( $title->isTalkPage() ) { + $other = $title->getSubjectPage(); + } else { + $other = $title->getTalkPage(); + } + + $other->invalidateCache(); + $other->purgeSquid(); + + $title->touchLinks(); + $title->purgeSquid(); + + // File cache + HTMLFileCache::clearFileCache( $title ); + InfoAction::invalidateCache( $title ); + + // Messages + if ( $title->getNamespace() == NS_MEDIAWIKI ) { + MessageCache::singleton()->replace( $title->getDBkey(), false ); + } + + // Images + if ( $title->getNamespace() == NS_FILE ) { + $update = new HTMLCacheUpdate( $title, 'imagelinks' ); + $update->doUpdate(); + } + + // User talk pages + if ( $title->getNamespace() == NS_USER_TALK ) { + $user = User::newFromName( $title->getText(), false ); + if ( $user ) { + $user->setNewtalk( false ); + } + } + + // Image redirects + RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title ); + } + + /** + * Purge caches on page update etc + * + * @param Title $title + * @todo Verify that $title is always a Title object (and never false or + * null), add Title hint to parameter $title. + */ + public static function onArticleEdit( $title ) { + // Invalidate caches of articles which include this page + DeferredUpdates::addHTMLCacheUpdate( $title, 'templatelinks' ); + + // Invalidate the caches of all pages which redirect here + DeferredUpdates::addHTMLCacheUpdate( $title, 'redirect' ); + + // Purge squid for this page only + $title->purgeSquid(); + + // Clear file cache for this page only + HTMLFileCache::clearFileCache( $title ); + InfoAction::invalidateCache( $title ); + } + + /**#@-*/ + + /** + * Returns a list of categories this page is a member of. + * Results will include hidden categories + * + * @return TitleArray + */ + public function getCategories() { + $id = $this->getId(); + if ( $id == 0 ) { + return TitleArray::newFromResult( new FakeResultWrapper( array() ) ); + } + + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'categorylinks', + array( 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ), + // Have to do that since DatabaseBase::fieldNamesWithAlias treats numeric indexes + // as not being aliases, and NS_CATEGORY is numeric + array( 'cl_from' => $id ), + __METHOD__ ); + + return TitleArray::newFromResult( $res ); + } + + /** + * Returns a list of hidden categories this page is a member of. + * Uses the page_props and categorylinks tables. + * + * @return array Array of Title objects + */ + public function getHiddenCategories() { + $result = array(); + $id = $this->getId(); + + if ( $id == 0 ) { + return array(); + } + + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( array( 'categorylinks', 'page_props', 'page' ), + array( 'cl_to' ), + array( 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat', + 'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ), + __METHOD__ ); + + if ( $res !== false ) { + foreach ( $res as $row ) { + $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to ); + } + } + + return $result; + } + + /** + * Return an applicable autosummary if one exists for the given edit. + * @param string|null $oldtext The previous text of the page. + * @param string|null $newtext The submitted text of the page. + * @param int $flags Bitmask: a bitmask of flags submitted for the edit. + * @return string An appropriate autosummary, or an empty string. + * + * @deprecated since 1.21, use ContentHandler::getAutosummary() instead + */ + public static function getAutosummary( $oldtext, $newtext, $flags ) { + // NOTE: stub for backwards-compatibility. assumes the given text is + // wikitext. will break horribly if it isn't. + + ContentHandler::deprecated( __METHOD__, '1.21' ); + + $handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + $oldContent = is_null( $oldtext ) ? null : $handler->unserializeContent( $oldtext ); + $newContent = is_null( $newtext ) ? null : $handler->unserializeContent( $newtext ); + + return $handler->getAutosummary( $oldContent, $newContent, $flags ); + } + + /** + * Auto-generates a deletion reason + * + * @param bool &$hasHistory Whether the page has a history + * @return string|bool String containing deletion reason or empty string, or boolean false + * if no revision occurred + */ + public function getAutoDeleteReason( &$hasHistory ) { + return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory ); + } + + /** + * Update all the appropriate counts in the category table, given that + * we've added the categories $added and deleted the categories $deleted. + * + * @param array $added The names of categories that were added + * @param array $deleted The names of categories that were deleted + */ + public function updateCategoryCounts( array $added, array $deleted ) { + $that = $this; + $method = __METHOD__; + $dbw = wfGetDB( DB_MASTER ); + + // Do this at the end of the commit to reduce lock wait timeouts + $dbw->onTransactionPreCommitOrIdle( + function () use ( $dbw, $that, $method, $added, $deleted ) { + $ns = $that->getTitle()->getNamespace(); + + $addFields = array( 'cat_pages = cat_pages + 1' ); + $removeFields = array( 'cat_pages = cat_pages - 1' ); + if ( $ns == NS_CATEGORY ) { + $addFields[] = 'cat_subcats = cat_subcats + 1'; + $removeFields[] = 'cat_subcats = cat_subcats - 1'; + } elseif ( $ns == NS_FILE ) { + $addFields[] = 'cat_files = cat_files + 1'; + $removeFields[] = 'cat_files = cat_files - 1'; + } + + if ( count( $added ) ) { + $insertRows = array(); + foreach ( $added as $cat ) { + $insertRows[] = array( + 'cat_title' => $cat, + 'cat_pages' => 1, + 'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0, + 'cat_files' => ( $ns == NS_FILE ) ? 1 : 0, + ); + } + $dbw->upsert( + 'category', + $insertRows, + array( 'cat_title' ), + $addFields, + $method + ); + } + + if ( count( $deleted ) ) { + $dbw->update( + 'category', + $removeFields, + array( 'cat_title' => $deleted ), + $method + ); + } + + foreach ( $added as $catName ) { + $cat = Category::newFromName( $catName ); + wfRunHooks( 'CategoryAfterPageAdded', array( $cat, $that ) ); + } + + foreach ( $deleted as $catName ) { + $cat = Category::newFromName( $catName ); + wfRunHooks( 'CategoryAfterPageRemoved', array( $cat, $that ) ); + } + } + ); + } + + /** + * Updates cascading protections + * + * @param ParserOutput $parserOutput ParserOutput object for the current version + */ + public function doCascadeProtectionUpdates( ParserOutput $parserOutput ) { + if ( wfReadOnly() || !$this->mTitle->areRestrictionsCascading() ) { + return; + } + + // templatelinks or imagelinks tables may have become out of sync, + // especially if using variable-based transclusions. + // For paranoia, check if things have changed and if + // so apply updates to the database. This will ensure + // that cascaded protections apply as soon as the changes + // are visible. + + // Get templates from templatelinks and images from imagelinks + $id = $this->getId(); + + $dbLinks = array(); + + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( array( 'templatelinks' ), + array( 'tl_namespace', 'tl_title' ), + array( 'tl_from' => $id ), + __METHOD__ + ); + + foreach ( $res as $row ) { + $dbLinks["{$row->tl_namespace}:{$row->tl_title}"] = true; + } + + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( array( 'imagelinks' ), + array( 'il_to' ), + array( 'il_from' => $id ), + __METHOD__ + ); + + foreach ( $res as $row ) { + $dbLinks[NS_FILE . ":{$row->il_to}"] = true; + } + + // Get templates and images from parser output. + $poLinks = array(); + foreach ( $parserOutput->getTemplates() as $ns => $templates ) { + foreach ( $templates as $dbk => $id ) { + $poLinks["$ns:$dbk"] = true; + } + } + foreach ( $parserOutput->getImages() as $dbk => $id ) { + $poLinks[NS_FILE . ":$dbk"] = true; + } + + // Get the diff + $links_diff = array_diff_key( $poLinks, $dbLinks ); + + if ( count( $links_diff ) > 0 ) { + // Whee, link updates time. + // Note: we are only interested in links here. We don't need to get + // other DataUpdate items from the parser output. + $u = new LinksUpdate( $this->mTitle, $parserOutput, false ); + $u->doUpdate(); + } + } + + /** + * Return a list of templates used by this article. + * Uses the templatelinks table + * + * @deprecated since 1.19; use Title::getTemplateLinksFrom() + * @return array Array of Title objects + */ + public function getUsedTemplates() { + return $this->mTitle->getTemplateLinksFrom(); + } + + /** + * This function is called right before saving the wikitext, + * so we can do things like signatures and links-in-context. + * + * @deprecated since 1.19; use Parser::preSaveTransform() instead + * @param string $text Article contents + * @param User $user User doing the edit + * @param ParserOptions $popts Parser options, default options for + * the user loaded if null given + * @return string Article contents with altered wikitext markup (signatures + * converted, {{subst:}}, templates, etc.) + */ + public function preSaveTransform( $text, User $user = null, ParserOptions $popts = null ) { + global $wgParser, $wgUser; + + wfDeprecated( __METHOD__, '1.19' ); + + $user = is_null( $user ) ? $wgUser : $user; + + if ( $popts === null ) { + $popts = ParserOptions::newFromUser( $user ); + } + + return $wgParser->preSaveTransform( $text, $this->mTitle, $user, $popts ); + } + + /** + * Update the article's restriction field, and leave a log entry. + * + * @deprecated since 1.19 + * @param array $limit Set of restriction keys + * @param string $reason + * @param int &$cascade Set to false if cascading protection isn't allowed. + * @param array $expiry Per restriction type expiration + * @param User $user The user updating the restrictions + * @return bool True on success + */ + public function updateRestrictions( + $limit = array(), $reason = '', &$cascade = 0, $expiry = array(), User $user = null + ) { + global $wgUser; + + $user = is_null( $user ) ? $wgUser : $user; + + return $this->doUpdateRestrictions( $limit, $expiry, $cascade, $reason, $user )->isOK(); + } + + /** + * Returns a list of updates to be performed when this page is deleted. The + * updates should remove any information about this page from secondary data + * stores such as links tables. + * + * @param Content|null $content Optional Content object for determining the + * necessary updates. + * @return array An array of DataUpdates objects + */ + public function getDeletionUpdates( Content $content = null ) { + if ( !$content ) { + // load content object, which may be used to determine the necessary updates + // XXX: the content may not be needed to determine the updates, then this would be overhead. + $content = $this->getContent( Revision::RAW ); + } + + if ( !$content ) { + $updates = array(); + } else { + $updates = $content->getDeletionUpdates( $this ); + } + + wfRunHooks( 'WikiPageDeletionUpdates', array( $this, $content, &$updates ) ); + return $updates; + } +} |