summaryrefslogtreecommitdiff
path: root/www/wiki/includes/page
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/page
first commit
Diffstat (limited to 'www/wiki/includes/page')
-rw-r--r--www/wiki/includes/page/Article.php2667
-rw-r--r--www/wiki/includes/page/CategoryPage.php128
-rw-r--r--www/wiki/includes/page/ImageHistoryList.php326
-rw-r--r--www/wiki/includes/page/ImageHistoryPseudoPager.php228
-rw-r--r--www/wiki/includes/page/ImagePage.php1229
-rw-r--r--www/wiki/includes/page/Page.php25
-rw-r--r--www/wiki/includes/page/PageArchive.php752
-rw-r--r--www/wiki/includes/page/WikiCategoryPage.php64
-rw-r--r--www/wiki/includes/page/WikiFilePage.php259
-rw-r--r--www/wiki/includes/page/WikiPage.php3768
10 files changed, 9446 insertions, 0 deletions
diff --git a/www/wiki/includes/page/Article.php b/www/wiki/includes/page/Article.php
new file mode 100644
index 00000000..8fff6147
--- /dev/null
+++ b/www/wiki/includes/page/Article.php
@@ -0,0 +1,2667 @@
+<?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
+ */
+use MediaWiki\MediaWikiServices;
+
+/**
+ * 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.
+ */
+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;
+
+ /**
+ * @var bool Whether render() was called. With the way subclasses work
+ * here, there doesn't seem to be any other way to stop calling
+ * OutputPage::enableSectionEditLinks() and still have it work as it did before.
+ */
+ private $disableSectionEditForRender = false;
+
+ /**
+ * 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 );
+ return $t == null ? null : new static( $t );
+ }
+
+ /**
+ * 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;
+ Hooks::run( 'ArticleFromTitle', [ &$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;
+ }
+
+ /**
+ * Get the page this view was redirected from
+ * @return Title|null
+ * @since 1.28
+ */
+ public function getRedirectedFrom() {
+ return $this->mRedirectedFrom;
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * Returns a Content object representing the pages effective display content,
+ * not necessarily the revision's content!
+ *
+ * Note that getContent does 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() {
+ 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;
+ }
+
+ 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 = [ 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ $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', [ $oldid ] );
+
+ 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" );
+ return false;
+ }
+ }
+ } else {
+ $oldid = $this->mPage->getLatest();
+ if ( !$oldid ) {
+ wfDebug( __METHOD__ . " failed to find page data for title " .
+ $this->getTitle()->getPrefixedText() . "\n" );
+ return false;
+ }
+
+ # Update error message with correct oldid
+ $this->mContentObject = new MessageContent( 'missing-revision', [ $oldid ] );
+
+ $this->mRevision = $this->mPage->getRevision();
+
+ if ( !$this->mRevision ) {
+ wfDebug( __METHOD__ . " failed to retrieve current page, rev_id $oldid\n" );
+ 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
+ $content = $this->mRevision->getContent(
+ Revision::FOR_THIS_USER,
+ $this->getContext()->getUser()
+ );
+
+ if ( !$content ) {
+ wfDebug( __METHOD__ . " failed to retrieve content of revision " .
+ $this->mRevision->getId() . "\n" );
+ return false;
+ }
+
+ $this->mContentObject = $content;
+ $this->mRevIdFetched = $this->mRevision->getId();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ Hooks::run(
+ 'ArticleAfterFetchContentObject',
+ [ &$articlePage, &$this->mContentObject ]
+ );
+
+ 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, $wgDebugToolbar;
+
+ # 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" );
+ 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" );
+
+ 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();
+
+ return;
+ }
+
+ # Set page title (may be overridden by DISPLAYTITLE)
+ $outputPage->setPageTitle( $this->getTitle()->getPrefixedText() );
+
+ $outputPage->setArticleFlag( true );
+ # Allow frames by default
+ $outputPage->allowClickjacking();
+
+ $parserCache = MediaWikiServices::getInstance()->getParserCache();
+
+ $parserOptions = $this->getParserOptions();
+ $poOptions = [];
+ # Render printable version, use printable version cache
+ if ( $outputPage->isPrintable() ) {
+ $parserOptions->setIsPrintable( true );
+ $poOptions['enableSectionEditLinks'] = false;
+ } elseif ( $this->disableSectionEditForRender
+ || !$this->isCurrent() || !$this->getTitle()->quickUserCan( 'edit', $user )
+ ) {
+ $poOptions['enableSectionEditLinks'] = false;
+ }
+
+ # Try client and file cache
+ if ( !$wgDebugToolbar && $oldid === 0 && $this->mPage->checkTouched() ) {
+ # Try to stream the output from file cache
+ if ( $wgUseFileCache && $this->tryFileCache() ) {
+ wfDebug( __METHOD__ . ": done file cache\n" );
+ # tell wgOut that output is taken care of
+ $outputPage->disable();
+ $this->mPage->doViewUpdates( $user, $oldid );
+
+ return;
+ }
+ }
+
+ # Should the parser cache be used?
+ $useParserCache = $this->mPage->shouldCheckParserCache( $parserOptions, $oldid );
+ wfDebug( 'Article::view using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
+ if ( $user->getStubThreshold() ) {
+ MediaWikiServices::getInstance()->getStatsdDataFactory()->increment( '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:
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+ Hooks::run( 'ArticleViewHeader', [ &$articlePage, &$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 );
+ return;
+ }
+
+ # Try the parser cache
+ if ( $useParserCache ) {
+ $this->mParserOutput = $parserCache->get( $this->mPage, $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, $poOptions );
+ # 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" );
+ 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->mPage->getTimestamp() );
+
+ # Pages containing custom CSS or JavaScript get special treatment
+ if ( $this->getTitle()->isSiteConfigPage() || $this->getTitle()->isUserConfigPage() ) {
+ $dir = $this->getContext()->getLanguage()->getDir();
+ $lang = $this->getContext()->getLanguage()->getHtmlCode();
+
+ $outputPage->wrapWikiMsg(
+ "<div id='mw-clearyourcache' lang='$lang' dir='$dir' class='mw-content-$dir'>\n$1\n</div>",
+ 'clearyourcache'
+ );
+ } elseif ( !Hooks::run( 'ArticleContentViewCustom',
+ [ $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( Html::errorBox( $errortext ) );
+ }
+ # Connection or timeout error
+ return;
+ }
+
+ $this->mParserOutput = $poolArticleView->getParserOutput();
+ $outputPage->addParserOutput( $this->mParserOutput, $poOptions );
+ if ( $content->getRedirectTarget() ) {
+ $outputPage->addSubtitle( "<span id=\"redirectsub\">" .
+ $this->getContext()->msg( 'redirectpagesub' )->parse() . "</span>" );
+ }
+
+ # Don't cache a dirty ParserOutput object
+ if ( $poolArticleView->getIsDirty() ) {
+ $outputPage->setCdnMaxage( 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*/oldid 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() );
+ }
+ }
+
+ # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often.
+ # This could use getTouched(), but that could be scary for major template edits.
+ $outputPage->adaptCdnTTL( $this->mPage->getTimestamp(), IExpiringStore::TTL_DAY );
+
+ # 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 );
+
+ # Load the postEdit module if the user just saved this revision
+ # See also EditPage::setPostEditCookie
+ $request = $this->getContext()->getRequest();
+ $cookieKey = EditPage::POST_EDIT_COOKIE_KEY_PREFIX . $this->getRevIdFetched();
+ $postEdit = $request->getCookie( $cookieKey );
+ if ( $postEdit ) {
+ # Clear the cookie. This also prevents caching of the response.
+ $request->response()->clearCookie( $cookieKey );
+ $outputPage->addJsConfigVars( 'wgPostEdit', $postEdit );
+ $outputPage->addModules( 'mediawiki.action.view.postEdit' );
+ }
+ }
+
+ /**
+ * 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.
+ */
+ protected 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' ) );
+ $msg = $this->getContext()->msg( 'difference-missing-revision' )
+ ->params( $oldid )
+ ->numParams( 1 )
+ ->parseAsBlock();
+ $this->getContext()->getOutput()->addHTML( $msg );
+ 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 );
+ }
+
+ /**
+ * 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 (T13443)
+ 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 [
+ 'index' => 'noindex',
+ 'follow' => 'nofollow'
+ ];
+ }
+ }
+
+ if ( $this->mPage->getId() === 0 || $this->getOldID() ) {
+ # Non-articles (special pages etc), and old revisions
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'nofollow'
+ ];
+ } elseif ( $this->getContext()->getOutput()->isPrintable() ) {
+ # Discourage indexing of printable versions, but encourage following
+ return [
+ 'index' => 'noindex',
+ 'follow' => 'follow'
+ ];
+ } elseif ( $this->getContext()->getRequest()->getInt( 'curid' ) ) {
+ # For ?curid=x urls, disallow indexing
+ return [
+ '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,
+ [ 'index' => $pOutput->getIndexPolicy() ]
+ );
+ }
+
+ if ( isset( $wgArticleRobotPolicies[$this->getTitle()->getPrefixedText()] ) ) {
+ # (T16900) 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 [];
+ }
+
+ $policy = explode( ',', $policy );
+ $policy = array_map( 'trim', $policy );
+
+ $arr = [];
+ foreach ( $policy as $var ) {
+ if ( in_array( $var, [ 'index', 'noindex' ] ) ) {
+ $arr['index'] = $var;
+ } elseif ( in_array( $var, [ '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;
+
+ $context = $this->getContext();
+ $outputPage = $context->getOutput();
+ $request = $context->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 ) ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ // This is an internally redirected page view.
+ // We'll need a backlink to the source page for navigation.
+ if ( Hooks::run( 'ArticleViewRedirect', [ &$articlePage ] ) ) {
+ $redir = Linker::linkKnown(
+ $this->mRedirectedFrom,
+ null,
+ [],
+ [ 'redirect' => 'no' ]
+ );
+
+ $outputPage->addSubtitle( "<span class=\"mw-redirectedfrom\">" .
+ $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
+ . "</span>" );
+
+ // Add the script to update the displayed URL and
+ // set the fragment if one was specified in the redirect
+ $outputPage->addJsConfigVars( [
+ 'wgInternalRedirectTargetUrl' => $redirectTargetUrl,
+ ] );
+ $outputPage->addModules( 'mediawiki.action.view.redirect' );
+
+ // Add a <link rel="canonical"> tag
+ $outputPage->setCanonicalUrl( $this->getTitle()->getCanonicalURL() );
+
+ // 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( "<span class=\"mw-redirectedfrom\">" .
+ $context->msg( 'redirectedfrom' )->rawParams( $redir )->parse()
+ . "</span>" );
+
+ // Add the script to update the displayed URL
+ $outputPage->addJsConfigVars( [
+ '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>",
+ [ '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();
+
+ Hooks::run( 'ArticleViewFooter', [ $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, $wgUseFilePatrol, $wgEnableAPI, $wgEnableWriteAPI;
+
+ $outputPage = $this->getContext()->getOutput();
+ $user = $this->getContext()->getUser();
+ $title = $this->getTitle();
+ $rc = false;
+
+ if ( !$title->quickUserCan( 'patrol', $user )
+ || !( $wgUseRCPatrol || $wgUseNPPatrol
+ || ( $wgUseFilePatrol && $title->inNamespace( NS_FILE ) ) )
+ ) {
+ // Patrolling is disabled or the user isn't allowed to
+ 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
+ return false;
+ }
+
+ // Check for cached results
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $key = $cache->makeKey( 'unpatrollable-page', $title->getArticleID() );
+ if ( $cache->get( $key ) ) {
+ return false;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $oldestRevisionTimestamp = $dbr->selectField(
+ 'revision',
+ 'MIN( rev_timestamp )',
+ [ 'rev_page' => $title->getArticleID() ],
+ __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).
+ $recentPageCreation = false;
+ if ( $oldestRevisionTimestamp
+ && RecentChange::isInRCLifespan( $oldestRevisionTimestamp, 21600 )
+ ) {
+ // 6h tolerance because the RC might not be cleaned out regularly
+ $recentPageCreation = true;
+ $rc = RecentChange::newFromConds(
+ [
+ 'rc_new' => 1,
+ 'rc_timestamp' => $oldestRevisionTimestamp,
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_cur_id' => $title->getArticleID()
+ ],
+ __METHOD__
+ );
+ if ( $rc ) {
+ // Use generic patrol message for new pages
+ $markPatrolledMsg = wfMessage( 'markaspatrolledtext' );
+ }
+ }
+
+ // File patrol: Get the timestamp of the latest upload for this page,
+ // check whether it is within the RC lifespan and if it is, we try
+ // to get the recentchanges row belonging to that entry
+ // (with rc_type = RC_LOG, rc_log_type = upload).
+ $recentFileUpload = false;
+ if ( ( !$rc || $rc->getAttribute( 'rc_patrolled' ) ) && $wgUseFilePatrol
+ && $title->getNamespace() === NS_FILE ) {
+ // Retrieve timestamp of most recent upload
+ $newestUploadTimestamp = $dbr->selectField(
+ 'image',
+ 'MAX( img_timestamp )',
+ [ 'img_name' => $title->getDBkey() ],
+ __METHOD__
+ );
+ if ( $newestUploadTimestamp
+ && RecentChange::isInRCLifespan( $newestUploadTimestamp, 21600 )
+ ) {
+ // 6h tolerance because the RC might not be cleaned out regularly
+ $recentFileUpload = true;
+ $rc = RecentChange::newFromConds(
+ [
+ 'rc_type' => RC_LOG,
+ 'rc_log_type' => 'upload',
+ 'rc_timestamp' => $newestUploadTimestamp,
+ 'rc_namespace' => NS_FILE,
+ 'rc_cur_id' => $title->getArticleID()
+ ],
+ __METHOD__
+ );
+ if ( $rc ) {
+ // Use patrol message specific to files
+ $markPatrolledMsg = wfMessage( 'markaspatrolledtext-file' );
+ }
+ }
+ }
+
+ if ( !$recentPageCreation && !$recentFileUpload ) {
+ // Page creation and latest upload (for files) is too old to be in RC
+
+ // We definitely can't patrol so cache the information
+ // When a new file version is uploaded, the cache is cleared
+ $cache->set( $key, '1' );
+
+ return false;
+ }
+
+ if ( !$rc ) {
+ // Don't cache: This can be hit if the page gets accessed very fast after
+ // its creation / latest upload or in case we have high replica DB lag. In case
+ // the revision is too old, we will already return above.
+ return false;
+ }
+
+ if ( $rc->getAttribute( 'rc_patrolled' ) ) {
+ // Patrolled 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( $key, '1' );
+
+ return false;
+ }
+
+ if ( $rc->getPerformer()->equals( $user ) ) {
+ // Don't show a patrol link for own creations/uploads. If the user could
+ // patrol them, they already would be patrolled
+ return false;
+ }
+
+ $outputPage->preventClickjacking();
+ if ( $wgEnableAPI && $wgEnableWriteAPI && $user->isAllowed( 'writeapi' ) ) {
+ $outputPage->addModules( 'mediawiki.page.patrol.ajax' );
+ }
+
+ $link = Linker::linkKnown(
+ $title,
+ $markPatrolledMsg->escaped(),
+ [],
+ [
+ 'action' => 'markpatrolled',
+ 'rcid' => $rc->getAttribute( 'rc_id' ),
+ ]
+ );
+
+ $outputPage->addHTML(
+ "<div class='patrollink' data-mw='interface'>" .
+ wfMessage( 'markaspatrolledlink' )->rawParams( $link )->escaped() .
+ '</div>'
+ );
+
+ return true;
+ }
+
+ /**
+ * Purge the cache used to check if it is worth showing the patrol footer
+ * For example, it is done during re-uploads when file patrol is used.
+ * @param int $articleID ID of the article to purge
+ * @since 1.27
+ */
+ public static function purgePatrolFooterCache( $articleID ) {
+ $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
+ $cache->delete( $cache->makeKey( 'unpatrollable-page', $articleID ) );
+ }
+
+ /**
+ * 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
+ ) {
+ $rootPart = explode( '/', $title->getText() )[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>",
+ [ '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(),
+ '',
+ [
+ 'lim' => 1,
+ 'showIfEmpty' => false,
+ 'msgKey' => [
+ 'blocked-notice-logextract',
+ $user->getName() # Support GENDER in notice
+ ]
+ ]
+ );
+ $validUserPage = !$title->isSubpage();
+ } else {
+ $validUserPage = !$title->isSubpage();
+ }
+ }
+
+ Hooks::run( 'ShowMissingArticle', [ $this ] );
+
+ # Show delete and move logs if there were any such events.
+ # The logging query can DOS the site when bots/crawlers cause 404 floods,
+ # so be careful showing this. 404 pages must be cheap as they are hard to cache.
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'page-recent-delete', md5( $title->getPrefixedText() ) );
+ $loggedIn = $this->getContext()->getUser()->isLoggedIn();
+ $sessionExists = $this->getContext()->getRequest()->getSession()->isPersistent();
+ if ( $loggedIn || $cache->get( $key ) || $sessionExists ) {
+ $logTypes = [ 'delete', 'move', 'protect' ];
+
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $conds = [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ];
+ // Give extensions a chance to hide their (unrelated) log entries
+ Hooks::run( 'Article::MissingArticleConditions', [ &$conds, $logTypes ] );
+ LogEventsList::showLogExtract(
+ $outputPage,
+ $logTypes,
+ $title,
+ '',
+ [
+ 'lim' => 10,
+ 'conds' => $conds,
+ 'showIfEmpty' => false,
+ 'msgKey' => [ $loggedIn || $sessionExists
+ ? 'moveddeleted-notice'
+ : 'moveddeleted-notice-recent'
+ ]
+ ]
+ );
+ }
+
+ 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()->statusHeader( 404 );
+ }
+
+ // 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 = Hooks::run( 'BeforeDisplayNoArticleText', [ $this ] );
+
+ if ( !$hookResult ) {
+ return;
+ }
+
+ # Show error message
+ $oldid = $this->getOldID();
+ if ( !$oldid && $title->getNamespace() === NS_MEDIAWIKI && $title->hasSourceText() ) {
+ $outputPage->addParserOutput( $this->getContentObject()->getParserOutput( $title ) );
+ } else {
+ if ( $oldid ) {
+ $text = wfMessage( 'missing-revision', $oldid )->plain();
+ } 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();
+ }
+
+ $dir = $this->getContext()->getLanguage()->getDir();
+ $lang = $this->getContext()->getLanguage()->getHtmlCode();
+ $outputPage->addWikiText( Xml::openElement( 'div', [
+ 'class' => "noarticletext mw-content-$dir",
+ 'dir' => $dir,
+ 'lang' => $lang,
+ ] ) . "\n$text\n</div>" );
+ }
+ }
+
+ /**
+ * 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",
+ [ $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 ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+
+ if ( !Hooks::run( 'DisplayOldSubtitle', [ &$articlePage, &$oldid ] ) ) {
+ return;
+ }
+
+ $context = $this->getContext();
+ $unhide = $context->getRequest()->getInt( 'unhide' ) == 1;
+
+ # Cascade unhide param in links for easy deletion browsing
+ $extraParams = [];
+ 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 = $context->getLanguage();
+ $user = $context->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 && !$context->msg( 'revision-info-current' )->isDisabled()
+ ? 'revision-info-current'
+ : 'revision-info';
+
+ $outputPage = $context->getOutput();
+ $revisionInfo = "<div id=\"mw-{$infomsg}\">" .
+ $context->msg( $infomsg, $td )
+ ->rawParams( $userlinks )
+ ->params( $revision->getId(), $tddate, $tdtime, $revision->getUserText() )
+ ->rawParams( Linker::revComment( $revision, true, true ) )
+ ->parse() .
+ "</div>";
+
+ $lnk = $current
+ ? $context->msg( 'currentrevisionlink' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'currentrevisionlink' )->escaped(),
+ [],
+ $extraParams
+ );
+ $curdiff = $current
+ ? $context->msg( 'diff' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'cur',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+ $prev = $this->getTitle()->getPreviousRevisionID( $oldid );
+ $prevlink = $prev
+ ? Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'previousrevision' )->escaped(),
+ [],
+ [
+ 'direction' => 'prev',
+ 'oldid' => $oldid
+ ] + $extraParams
+ )
+ : $context->msg( 'previousrevision' )->escaped();
+ $prevdiff = $prev
+ ? Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'prev',
+ 'oldid' => $oldid
+ ] + $extraParams
+ )
+ : $context->msg( 'diff' )->escaped();
+ $nextlink = $current
+ ? $context->msg( 'nextrevision' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'nextrevision' )->escaped(),
+ [],
+ [
+ 'direction' => 'next',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+ $nextdiff = $current
+ ? $context->msg( 'diff' )->escaped()
+ : Linker::linkKnown(
+ $this->getTitle(),
+ $context->msg( 'diff' )->escaped(),
+ [],
+ [
+ 'diff' => 'next',
+ 'oldid' => $oldid
+ ] + $extraParams
+ );
+
+ $cdel = Linker::getRevDeleteLink( $user, $revision, $this->getTitle() );
+ if ( $cdel !== '' ) {
+ $cdel .= ' ';
+ }
+
+ // the outer div is need for styling the revision info and nav in MobileFrontend
+ $outputPage->addSubtitle( "<div class=\"mw-revision\">" . $revisionInfo .
+ "<div id=\"mw-revision-nav\">" . $cdel .
+ $context->msg( 'revision-nav' )->rawParams(
+ $prevdiff, $prevlink, $lnk, $curdiff, $nextlink, $nextdiff
+ )->escaped() . "</div></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
+ *
+ * @deprecated since 1.30
+ */
+ public function viewRedirect( $target, $appendSubtitle = true, $forceKnown = false ) {
+ $lang = $this->getTitle()->getPageLanguage();
+ $out = $this->getContext()->getOutput();
+ if ( $appendSubtitle ) {
+ $out->addSubtitle( wfMessage( 'redirectpagesub' ) );
+ }
+ $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 = [ $target ];
+ }
+
+ $html = '<ul class="redirectText">';
+ /** @var Title $title */
+ foreach ( $target as $title ) {
+ $html .= '<li>' . Linker::link(
+ $title,
+ htmlspecialchars( $title->getFullText() ),
+ [],
+ // Make sure wiki page redirects are not followed
+ $title->isRedirect() ? [ 'redirect' => 'no' ] : [],
+ ( $forceKnown ? [ 'known', 'noclasses' ] : [] )
+ ) . '</li>';
+ }
+ $html .= '</ul>';
+
+ $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->escaped();
+
+ return '<div class="redirectMsg">' .
+ '<p>' . $redirectToText . '</p>' .
+ $html .
+ '</div>';
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: 'namespace-' + namespace number + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ $msg = wfMessage(
+ 'namespace-' . $this->getTitle()->getNamespace() . '-helppage'
+ );
+
+ $out = $this->getContext()->getOutput();
+ if ( !$msg->isDisabled() ) {
+ $helpUrl = Skin::makeUrl( $msg->plain() );
+ $out->addHelpLink( $helpUrl, true );
+ } else {
+ $out->addHelpLink( $to, $overrideBaseUrl );
+ }
+ }
+
+ /**
+ * Handle action=render
+ */
+ public function render() {
+ $this->getContext()->getRequest()->response()->header( 'X-Robots-Tag: noindex' );
+ $this->getContext()->getOutput()->setArticleBodyOnly( true );
+ $this->disableSectionEditForRender = true;
+ $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();
+ $context = $this->getContext();
+ $user = $context->getUser();
+ $request = $context->getRequest();
+
+ # 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(
+ $request->wasPosted() ? WikiPage::READ_LATEST : WikiPage::READ_NORMAL
+ );
+ if ( !$this->mPage->exists() ) {
+ $deleteLogPage = new LogPage( 'delete' );
+ $outputPage = $context->getOutput();
+ $outputPage->setPageTitle( $context->msg( 'cannotdelete-title', $title->getPrefixedText() ) );
+ $outputPage->wrapWikiMsg( "<div class=\"error mw-error-cannotdelete\">\n$1\n</div>",
+ [ 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ]
+ );
+ $outputPage->addHTML(
+ Xml::element( 'h2', null, $deleteLogPage->getName()->text() )
+ );
+ LogEventsList::showLogExtract(
+ $outputPage,
+ 'delete',
+ $title
+ );
+
+ return;
+ }
+
+ $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' ),
+ [ 'delete', $this->getTitle()->getPrefixedText() ] )
+ ) {
+ # Flag to hide all contents of the archived revisions
+ $suppress = $request->getCheck( '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 ( Exception $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_REPLICA );
+ $revisions = $edits = (int)$dbr->selectField(
+ 'revision',
+ 'COUNT(rev_page)',
+ [ 'rev_page' => $title->getArticleID() ],
+ __METHOD__
+ );
+
+ // @todo FIXME: i18n issue/patchwork message
+ $context->getOutput()->addHTML(
+ '<strong class="mw-delete-warning-revisions">' .
+ $context->msg( 'historywarning' )->numParams( $revisions )->parse() .
+ $context->msg( 'word-separator' )->escaped() . Linker::linkKnown( $title,
+ $context->msg( 'history' )->escaped(),
+ [],
+ [ 'action' => 'history' ] ) .
+ '</strong>'
+ );
+
+ if ( $title->isBigDeletion() ) {
+ global $wgDeleteRevisionsLimit;
+ $context->getOutput()->wrapWikiMsg( "<div class='error'>\n$1\n</div>\n",
+ [
+ 'delete-warning-toobig',
+ $context->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();
+ $ctx = $this->getContext();
+ $outputPage = $ctx->getOutput();
+ $outputPage->setPageTitle( wfMessage( 'delete-confirm', $title->getPrefixedText() ) );
+ $outputPage->addBacklinkSubtitle( $title );
+ $outputPage->setRobotPolicy( 'noindex,nofollow' );
+ $outputPage->addModules( 'mediawiki.action.delete' );
+
+ $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' );
+ }
+
+ $subpageQueryLimit = 51;
+ $subpages = $title->getSubpages( $subpageQueryLimit );
+ $subpageCount = count( $subpages );
+ if ( $subpageCount > 0 ) {
+ $outputPage->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
+ [ 'deleting-subpages-warning', Message::numParam( $subpageCount ) ] );
+ }
+ $outputPage->addWikiMsg( 'confirmdeletetext' );
+
+ Hooks::run( 'ArticleConfirmDelete', [ $this, $outputPage, &$reason ] );
+
+ $user = $this->getContext()->getUser();
+ $checkWatch = $user->getBoolOption( 'watchdeletion' ) || $user->isWatched( $title );
+
+ $outputPage->enableOOUI();
+
+ $options = Xml::listDropDownOptions(
+ $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->text(),
+ [ 'other' => $ctx->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ]
+ );
+ $options = Xml::listDropDownOptionsOoui( $options );
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\DropdownInputWidget( [
+ 'name' => 'wpDeleteReasonList',
+ 'inputId' => 'wpDeleteReasonList',
+ 'tabIndex' => 1,
+ 'infusable' => true,
+ 'value' => '',
+ 'options' => $options
+ ] ),
+ [
+ 'label' => $ctx->msg( 'deletecomment' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
+ // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
+ // Unicode codepoints (or 255 UTF-8 bytes for old schema).
+ $conf = $this->getContext()->getConfig();
+ $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD;
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\TextInputWidget( [
+ 'name' => 'wpReason',
+ 'inputId' => 'wpReason',
+ 'tabIndex' => 2,
+ 'maxLength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT,
+ 'infusable' => true,
+ 'value' => $reason,
+ 'autofocus' => true,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'deleteotherreason' )->text(),
+ 'align' => 'top',
+ ]
+ );
+
+ if ( $user->isLoggedIn() ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpWatch',
+ 'inputId' => 'wpWatch',
+ 'tabIndex' => 3,
+ 'selected' => $checkWatch,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'watchthis' )->text(),
+ 'align' => 'inline',
+ 'infusable' => true,
+ ]
+ );
+ }
+
+ if ( $user->isAllowed( 'suppressrevision' ) ) {
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\CheckboxInputWidget( [
+ 'name' => 'wpSuppress',
+ 'inputId' => 'wpSuppress',
+ 'tabIndex' => 4,
+ ] ),
+ [
+ 'label' => $ctx->msg( 'revdelete-suppress' )->text(),
+ 'align' => 'inline',
+ 'infusable' => true,
+ ]
+ );
+ }
+
+ $fields[] = new OOUI\FieldLayout(
+ new OOUI\ButtonInputWidget( [
+ 'name' => 'wpConfirmB',
+ 'inputId' => 'wpConfirmB',
+ 'tabIndex' => 5,
+ 'value' => $ctx->msg( 'deletepage' )->text(),
+ 'label' => $ctx->msg( 'deletepage' )->text(),
+ 'flags' => [ 'primary', 'destructive' ],
+ 'type' => 'submit',
+ ] ),
+ [
+ 'align' => 'top',
+ ]
+ );
+
+ $fieldset = new OOUI\FieldsetLayout( [
+ 'label' => $ctx->msg( 'delete-legend' )->text(),
+ 'id' => 'mw-delete-table',
+ 'items' => $fields,
+ ] );
+
+ $form = new OOUI\FormLayout( [
+ 'method' => 'post',
+ 'action' => $title->getLocalURL( 'action=delete' ),
+ 'id' => 'deleteconfirm',
+ ] );
+ $form->appendContent(
+ $fieldset,
+ new OOUI\HtmlSnippet(
+ Html::hidden( 'wpEditToken', $user->getEditToken( [ 'delete', $title->getPrefixedText() ] ) )
+ )
+ );
+
+ $outputPage->addHTML(
+ new OOUI\PanelLayout( [
+ 'classes' => [ 'deletepage-wrapper' ],
+ 'expanded' => false,
+ 'padded' => true,
+ 'framed' => true,
+ 'content' => $form,
+ ] )
+ );
+
+ if ( $user->isAllowed( 'editinterface' ) ) {
+ $link = Linker::linkKnown(
+ $ctx->msg( 'deletereason-dropdown' )->inContentLanguage()->getTitle(),
+ wfMessage( 'delete-edit-reasonlist' )->escaped(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $outputPage->addHTML( '<p class="mw-delete-editreasons">' . $link . '</p>' );
+ }
+
+ $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 = '';
+ $context = $this->getContext();
+ $outputPage = $context->getOutput();
+ $user = $context->getUser();
+ $status = $this->mPage->doDeleteArticleReal( $reason, $suppress, 0, true, $error, $user );
+
+ 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 );
+
+ Hooks::run( 'ArticleDeleteAfterSuccess', [ $this->getTitle(), $outputPage ] );
+
+ $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( [ &$cache, 'saveToFileCache' ] );
+ }
+ } else {
+ wfDebug( "Article::tryFileCache(): not cacheable\n" );
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the page can be cached
+ * @param int $mode One of the HTMLFileCache::MODE_* constants (since 1.28)
+ * @return bool
+ */
+ public function isFileCacheable( $mode = HTMLFileCache::MODE_NORMAL ) {
+ $cacheable = false;
+
+ if ( HTMLFileCache::useFileCache( $this->getContext(), $mode ) ) {
+ $cacheable = $this->mPage->getId()
+ && !$this->mRedirectedFrom && !$this->getTitle()->isRedirect();
+ // Extension may have reason to disable file caching on some pages.
+ if ( $cacheable ) {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $articlePage = $this;
+ $cacheable = Hooks::run( 'IsFileCacheable', [ &$articlePage ] );
+ }
+ }
+
+ 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, [ 'mContext', 'mPage' ] ) ) {
+ $this->mPage->$fname = $fvalue;
+ } else {
+ trigger_error( 'Inaccessible property via __set(): ' . $fname, E_USER_NOTICE );
+ }
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::checkFlags
+ */
+ public function checkFlags( $flags ) {
+ return $this->mPage->checkFlags( $flags );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::checkTouched
+ */
+ public function checkTouched() {
+ return $this->mPage->checkTouched();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::clearPreparedEdit
+ */
+ public function clearPreparedEdit() {
+ $this->mPage->clearPreparedEdit();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doDeleteArticleReal
+ */
+ public function doDeleteArticleReal(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
+ $tags = []
+ ) {
+ return $this->mPage->doDeleteArticleReal(
+ $reason, $suppress, $u1, $u2, $error, $user, $tags
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doDeleteUpdates
+ */
+ public function doDeleteUpdates( $id, Content $content = null ) {
+ return $this->mPage->doDeleteUpdates( $id, $content );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @deprecated since 1.29. Use WikiPage::doEditContent() directly instead
+ * @see WikiPage::doEditContent
+ */
+ public function doEditContent( Content $content, $summary, $flags = 0, $baseRevId = false,
+ User $user = null, $serialFormat = null
+ ) {
+ wfDeprecated( __METHOD__, '1.29' );
+ return $this->mPage->doEditContent( $content, $summary, $flags, $baseRevId,
+ $user, $serialFormat
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doEditUpdates
+ */
+ public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
+ return $this->mPage->doEditUpdates( $revision, $user, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doPurge
+ * @note In 1.28 (and only 1.28), this took a $flags parameter that
+ * controlled how much purging was done.
+ */
+ public function doPurge() {
+ return $this->mPage->doPurge();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::doViewUpdates
+ */
+ public function doViewUpdates( User $user, $oldid = 0 ) {
+ $this->mPage->doViewUpdates( $user, $oldid );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::exists
+ */
+ public function exists() {
+ return $this->mPage->exists();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::followRedirect
+ */
+ public function followRedirect() {
+ return $this->mPage->followRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see ContentHandler::getActionOverrides
+ */
+ public function getActionOverrides() {
+ return $this->mPage->getActionOverrides();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getAutoDeleteReason
+ */
+ public function getAutoDeleteReason( &$hasHistory ) {
+ return $this->mPage->getAutoDeleteReason( $hasHistory );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getCategories
+ */
+ public function getCategories() {
+ return $this->mPage->getCategories();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getComment
+ */
+ public function getComment( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getComment( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContentHandler
+ */
+ public function getContentHandler() {
+ return $this->mPage->getContentHandler();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContentModel
+ */
+ public function getContentModel() {
+ return $this->mPage->getContentModel();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getContributors
+ */
+ public function getContributors() {
+ return $this->mPage->getContributors();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getCreator
+ */
+ public function getCreator( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getCreator( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getDeletionUpdates
+ */
+ public function getDeletionUpdates( Content $content = null ) {
+ return $this->mPage->getDeletionUpdates( $content );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getHiddenCategories
+ */
+ public function getHiddenCategories() {
+ return $this->mPage->getHiddenCategories();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getId
+ */
+ public function getId() {
+ return $this->mPage->getId();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getLatest
+ */
+ public function getLatest() {
+ return $this->mPage->getLatest();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getLinksTimestamp
+ */
+ public function getLinksTimestamp() {
+ return $this->mPage->getLinksTimestamp();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getMinorEdit
+ */
+ public function getMinorEdit() {
+ return $this->mPage->getMinorEdit();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getOldestRevision
+ */
+ public function getOldestRevision() {
+ return $this->mPage->getOldestRevision();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRedirectTarget
+ */
+ public function getRedirectTarget() {
+ return $this->mPage->getRedirectTarget();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRedirectURL
+ */
+ public function getRedirectURL( $rt ) {
+ return $this->mPage->getRedirectURL( $rt );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getRevision
+ */
+ public function getRevision() {
+ return $this->mPage->getRevision();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getTimestamp
+ */
+ public function getTimestamp() {
+ return $this->mPage->getTimestamp();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getTouched
+ */
+ public function getTouched() {
+ return $this->mPage->getTouched();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUndoContent
+ */
+ public function getUndoContent( Revision $undo, Revision $undoafter = null ) {
+ return $this->mPage->getUndoContent( $undo, $undoafter );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUser
+ */
+ public function getUser( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getUser( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::getUserText
+ */
+ public function getUserText( $audience = Revision::FOR_PUBLIC, User $user = null ) {
+ return $this->mPage->getUserText( $audience, $user );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::hasViewableContent
+ */
+ public function hasViewableContent() {
+ return $this->mPage->hasViewableContent();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertOn
+ */
+ public function insertOn( $dbw, $pageId = null ) {
+ return $this->mPage->insertOn( $dbw, $pageId );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertProtectNullRevision
+ */
+ public function insertProtectNullRevision( $revCommentMsg, array $limit,
+ array $expiry, $cascade, $reason, $user = null
+ ) {
+ return $this->mPage->insertProtectNullRevision( $revCommentMsg, $limit,
+ $expiry, $cascade, $reason, $user
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertRedirect
+ */
+ public function insertRedirect() {
+ return $this->mPage->insertRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::insertRedirectEntry
+ */
+ public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
+ return $this->mPage->insertRedirectEntry( $rt, $oldLatest );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::isCountable
+ */
+ public function isCountable( $editInfo = false ) {
+ return $this->mPage->isCountable( $editInfo );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::isRedirect
+ */
+ public function isRedirect() {
+ return $this->mPage->isRedirect();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::loadFromRow
+ */
+ public function loadFromRow( $data, $from ) {
+ return $this->mPage->loadFromRow( $data, $from );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::loadPageData
+ */
+ public function loadPageData( $from = 'fromdb' ) {
+ $this->mPage->loadPageData( $from );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::lockAndGetLatest
+ */
+ public function lockAndGetLatest() {
+ return $this->mPage->lockAndGetLatest();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::makeParserOptions
+ */
+ public function makeParserOptions( $context ) {
+ return $this->mPage->makeParserOptions( $context );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::pageDataFromId
+ */
+ public function pageDataFromId( $dbr, $id, $options = [] ) {
+ return $this->mPage->pageDataFromId( $dbr, $id, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::pageDataFromTitle
+ */
+ public function pageDataFromTitle( $dbr, $title, $options = [] ) {
+ return $this->mPage->pageDataFromTitle( $dbr, $title, $options );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::prepareContentForEdit
+ */
+ public function prepareContentForEdit(
+ Content $content, $revision = null, User $user = null,
+ $serialFormat = null, $useCache = true
+ ) {
+ return $this->mPage->prepareContentForEdit(
+ $content, $revision, $user,
+ $serialFormat, $useCache
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::protectDescription
+ */
+ public function protectDescription( array $limit, array $expiry ) {
+ return $this->mPage->protectDescription( $limit, $expiry );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::protectDescriptionLog
+ */
+ public function protectDescriptionLog( array $limit, array $expiry ) {
+ return $this->mPage->protectDescriptionLog( $limit, $expiry );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::replaceSectionAtRev
+ */
+ public function replaceSectionAtRev( $sectionId, Content $sectionContent,
+ $sectionTitle = '', $baseRevId = null
+ ) {
+ return $this->mPage->replaceSectionAtRev( $sectionId, $sectionContent,
+ $sectionTitle, $baseRevId
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::replaceSectionContent
+ */
+ public function replaceSectionContent(
+ $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
+ ) {
+ return $this->mPage->replaceSectionContent(
+ $sectionId, $sectionContent, $sectionTitle, $edittime
+ );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::setTimestamp
+ */
+ public function setTimestamp( $ts ) {
+ return $this->mPage->setTimestamp( $ts );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::shouldCheckParserCache
+ */
+ public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
+ return $this->mPage->shouldCheckParserCache( $parserOptions, $oldId );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::supportsSections
+ */
+ public function supportsSections() {
+ return $this->mPage->supportsSections();
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::triggerOpportunisticLinksUpdate
+ */
+ public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
+ return $this->mPage->triggerOpportunisticLinksUpdate( $parserOutput );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateCategoryCounts
+ */
+ public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
+ return $this->mPage->updateCategoryCounts( $added, $deleted, $id );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateIfNewerOn
+ */
+ public function updateIfNewerOn( $dbw, $revision ) {
+ return $this->mPage->updateIfNewerOn( $dbw, $revision );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateRedirectOn
+ */
+ public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
+ return $this->mPage->updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect );
+ }
+
+ /**
+ * Call to WikiPage function for backwards compatibility.
+ * @see WikiPage::updateRevisionOn
+ */
+ public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
+ $lastRevIsRedirect = null
+ ) {
+ return $this->mPage->updateRevisionOn( $dbw, $revision, $lastRevision,
+ $lastRevIsRedirect
+ );
+ }
+
+ /**
+ * @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 = [], $reason = '',
+ &$cascade = 0, $expiry = []
+ ) {
+ return $this->mPage->doUpdateRestrictions(
+ $limit,
+ $expiry,
+ $cascade,
+ $reason,
+ $this->getContext()->getUser()
+ );
+ }
+
+ /**
+ * @param string $reason
+ * @param bool $suppress
+ * @param int $u1 Unused
+ * @param bool $u2 Unused
+ * @param string &$error
+ * @return bool
+ */
+ public function doDeleteArticle(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = ''
+ ) {
+ return $this->mPage->doDeleteArticle( $reason, $suppress, $u1, $u2, $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 );
+ }
+
+ // ******
+}
diff --git a/www/wiki/includes/page/CategoryPage.php b/www/wiki/includes/page/CategoryPage.php
new file mode 100644
index 00000000..491726be
--- /dev/null
+++ b/www/wiki/includes/page/CategoryPage.php
@@ -0,0 +1,128 @@
+<?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::class;
+
+ /**
+ * @var WikiCategoryPage
+ */
+ protected $mPage;
+
+ /**
+ * @param Title $title
+ * @return WikiCategoryPage
+ */
+ protected function newPage( Title $title ) {
+ // Overload mPage with a category-specific page
+ return new WikiCategoryPage( $title );
+ }
+
+ 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;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $categoryPage = $this;
+
+ if ( !Hooks::run( 'CategoryPageView', [ &$categoryPage ] ) ) {
+ return;
+ }
+
+ $title = $this->getTitle();
+ if ( $title->inNamespace( NS_CATEGORY ) ) {
+ $this->openShowCategory();
+ }
+
+ parent::view();
+
+ if ( $title->inNamespace( NS_CATEGORY ) ) {
+ $this->closeShowCategory();
+ }
+
+ # Use adaptive TTLs for CDN so delayed/failed purges are noticed less often
+ $outputPage = $this->getContext()->getOutput();
+ $outputPage->adaptCdnTTL( $this->mPage->getTouched(), IExpiringStore::TTL_MINUTE );
+ }
+
+ 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 = [];
+ foreach ( [ '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
+ );
+ $out = $this->getContext()->getOutput();
+ $out->addHTML( $viewer->getHTML() );
+ $this->addHelpLink( 'Help:Categories' );
+ }
+
+ function getCategoryViewerClass() {
+ return $this->mCategoryViewerClass;
+ }
+
+ function setCategoryViewerClass( $class ) {
+ $this->mCategoryViewerClass = $class;
+ }
+}
diff --git a/www/wiki/includes/page/ImageHistoryList.php b/www/wiki/includes/page/ImageHistoryList.php
new file mode 100644
index 00000000..bb8ed242
--- /dev/null
+++ b/www/wiki/includes/page/ImageHistoryList.php
@@ -0,0 +1,326 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Builds the image revision log shown on image pages
+ *
+ * @ingroup Media
+ */
+class ImageHistoryList extends ContextSource {
+
+ /**
+ * @var Title
+ */
+ protected $title;
+
+ /**
+ * @var File
+ */
+ protected $img;
+
+ /**
+ * @var ImagePage
+ */
+ protected $imagePage;
+
+ /**
+ * @var File
+ */
+ protected $current;
+
+ protected $repo, $showThumb;
+ protected $preventClickjacking = false;
+
+ /**
+ * @param ImagePage $imagePage
+ */
+ public function __construct( $imagePage ) {
+ global $wgShowArchiveThumbnails;
+ $this->current = $imagePage->getPage()->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', [ 'id' => 'filehistory' ], $this->msg( 'filehist' )->text() )
+ . "\n"
+ . "<div id=\"mw-imagepage-section-filehistory\">\n"
+ . $this->msg( 'filehist-help' )->parseAsBlock()
+ . $navLinks . "\n"
+ . Xml::openElement( 'table', [ 'class' => 'wikitable filehistory' ] ) . "\n"
+ . '<tr><th></th>'
+ . ( $this->current->isLocal()
+ && ( $this->getUser()->isAllowedAny( 'delete', 'deletedhistory' ) ) ? '<th></th>' : '' )
+ . '<th>' . $this->msg( 'filehist-datetime' )->escaped() . '</th>'
+ . ( $this->showThumb ? '<th>' . $this->msg( 'filehist-thumb' )->escaped() . '</th>' : '' )
+ . '<th>' . $this->msg( 'filehist-dimensions' )->escaped() . '</th>'
+ . '<th>' . $this->msg( 'filehist-user' )->escaped() . '</th>'
+ . '<th>' . $this->msg( 'filehist-comment' )->escaped() . '</th>'
+ . "</tr>\n";
+ }
+
+ /**
+ * @param string $navLinks
+ * @return string
+ */
+ public function endImageHistoryList( $navLinks = '' ) {
+ return "</table>\n$navLinks\n</div>\n";
+ }
+
+ /**
+ * @param bool $iscur
+ * @param File $file
+ * @return string
+ */
+ public function imageHistoryLine( $iscur, $file ) {
+ global $wgContLang;
+
+ $user = $this->getUser();
+ $lang = $this->getLanguage();
+ $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+ $img = $iscur ? $file->getName() : $file->getArchiveName();
+ $userId = $file->getUser( 'id' );
+ $userText = $file->getUser( 'text' );
+ $description = $file->getDescription( File::FOR_THIS_USER, $user );
+
+ $local = $this->current->isLocal();
+ $row = $selected = '';
+
+ // Deletion link
+ if ( $local && ( $user->isAllowedAny( 'delete', 'deletedhistory' ) ) ) {
+ $row .= '<td>';
+ # Link to remove from history
+ if ( $user->isAllowed( 'delete' ) ) {
+ $q = [ 'action' => 'delete' ];
+ if ( !$iscur ) {
+ $q['oldimage'] = $img;
+ }
+ $row .= Linker::linkKnown(
+ $this->title,
+ $this->msg( $iscur ? 'filehist-deleteall' : 'filehist-deleteone' )->escaped(),
+ [], $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 = [
+ '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(),
+ [],
+ [
+ 'action' => 'revert',
+ 'oldimage' => $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 ),
+ [],
+ [
+ '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',
+ [ 'href' => $url ],
+ $lang->userTimeAndDate( $timestamp, $user )
+ );
+ }
+ $row .= "</td>";
+
+ // Thumbnail
+ if ( $this->showThumb ) {
+ $row .= '<td>' . $this->getThumbForLine( $file ) . '</td>';
+ }
+
+ // Image dimensions + size
+ $row .= '<td>';
+ $row .= htmlspecialchars( $file->getDimensionsString() );
+ $row .= $this->msg( 'word-separator' )->escaped();
+ $row .= '<span style="white-space: nowrap;">';
+ $row .= $this->msg( 'parentheses' )->sizeParams( $file->getSize() )->escaped();
+ $row .= '</span>';
+ $row .= '</td>';
+
+ // Uploading user
+ $row .= '<td>';
+ // Hide deleted usernames
+ if ( $file->isDeleted( File::DELETED_USER ) ) {
+ $row .= '<span class="history-deleted">'
+ . $this->msg( 'rev-deleted-user' )->escaped() . '</span>';
+ } else {
+ if ( $local ) {
+ $row .= Linker::userLink( $userId, $userText );
+ $row .= '<span style="white-space: nowrap;">';
+ $row .= Linker::userToolLinks( $userId, $userText );
+ $row .= '</span>';
+ } else {
+ $row .= htmlspecialchars( $userText );
+ }
+ }
+ $row .= '</td>';
+
+ // Don't show deleted descriptions
+ if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
+ $row .= '<td><span class="history-deleted">' .
+ $this->msg( 'rev-deleted-comment' )->escaped() . '</span></td>';
+ } else {
+ $row .= '<td dir="' . $wgContLang->getDir() . '">' .
+ Linker::formatComment( $description, $this->title ) . '</td>';
+ }
+
+ $rowClass = null;
+ Hooks::run( 'ImagePageFileHistoryLine', [ $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 = [
+ 'width' => '120',
+ 'height' => '120',
+ ];
+ $timestamp = wfTimestamp( TS_MW, $file->getTimestamp() );
+
+ $thumbnail = $file->transform( $params );
+ $options = [
+ 'alt' => $this->msg( 'filehist-thumbtext',
+ $lang->userTimeAndDate( $timestamp, $user ),
+ $lang->userDate( $timestamp, $user ),
+ $lang->userTime( $timestamp, $user ) )->text(),
+ 'file-link' => true,
+ ];
+
+ if ( !$thumbnail ) {
+ return $this->msg( 'filehist-nothumb' )->escaped();
+ }
+
+ return $thumbnail->toHtml( $options );
+ } else {
+ return $this->msg( 'filehist-nothumb' )->escaped();
+ }
+ }
+
+ /**
+ * @param bool $enable
+ */
+ protected function preventClickjacking( $enable = true ) {
+ $this->preventClickjacking = $enable;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getPreventClickjacking() {
+ return $this->preventClickjacking;
+ }
+}
diff --git a/www/wiki/includes/page/ImageHistoryPseudoPager.php b/www/wiki/includes/page/ImageHistoryPseudoPager.php
new file mode 100644
index 00000000..20bc614b
--- /dev/null
+++ b/www/wiki/includes/page/ImageHistoryPseudoPager.php
@@ -0,0 +1,228 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class ImageHistoryPseudoPager extends ReverseChronologicalPager {
+ protected $preventClickjacking = false;
+
+ /**
+ * @var File
+ */
+ protected $mImg;
+
+ /**
+ * @var Title
+ */
+ protected $mTitle;
+
+ /**
+ * @since 1.14
+ * @var ImagePage
+ */
+ public $mImagePage;
+
+ /**
+ * @since 1.14
+ * @var File[]
+ */
+ public $mHist;
+
+ /**
+ * @since 1.14
+ * @var int[]
+ */
+ public $mRange;
+
+ /**
+ * @param ImagePage $imagePage
+ */
+ public function __construct( $imagePage ) {
+ parent::__construct( $imagePage->getContext() );
+ $this->mImagePage = $imagePage;
+ $this->mTitle = $imagePage->getTitle()->createFragmentTarget( 'filehistory' );
+ $this->mImg = null;
+ $this->mHist = [];
+ $this->mRange = [ 0, 0 ]; // display range
+
+ // Only display 10 revisions at once by default, otherwise the list is overwhelming
+ $this->mLimitsShown = array_merge( [ 10 ], $this->mLimitsShown );
+ $this->mDefaultLimit = 10;
+ list( $this->mLimit, /* $offset */ ) =
+ $this->mRequest->getLimitOffset( $this->mDefaultLimit, '' );
+ }
+
+ /**
+ * @return Title
+ */
+ public function getTitle() {
+ return $this->mTitle;
+ }
+
+ public function getQueryInfo() {
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getIndexField() {
+ return '';
+ }
+
+ /**
+ * @param object $row
+ * @return string
+ */
+ public function formatRow( $row ) {
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ public function getBody() {
+ $s = '';
+ $this->doQuery();
+ if ( count( $this->mHist ) ) {
+ if ( $this->mImg->isLocal() ) {
+ // Do a batch existence check for user pages and talkpages
+ $linkBatch = new LinkBatch();
+ for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+ $file = $this->mHist[$i];
+ $user = $file->getUser( 'text' );
+ $linkBatch->add( NS_USER, $user );
+ $linkBatch->add( NS_USER_TALK, $user );
+ }
+ $linkBatch->execute();
+ }
+
+ $list = new ImageHistoryList( $this->mImagePage );
+ # Generate prev/next links
+ $navLink = $this->getNavigationBar();
+ $s = $list->beginImageHistoryList( $navLink );
+ // Skip rows there just for paging links
+ for ( $i = $this->mRange[0]; $i <= $this->mRange[1]; $i++ ) {
+ $file = $this->mHist[$i];
+ $s .= $list->imageHistoryLine( !$file->isOld(), $file );
+ }
+ $s .= $list->endImageHistoryList( $navLink );
+
+ if ( $list->getPreventClickjacking() ) {
+ $this->preventClickjacking();
+ }
+ }
+ return $s;
+ }
+
+ public function doQuery() {
+ if ( $this->mQueryDone ) {
+ return;
+ }
+ $this->mImg = $this->mImagePage->getPage()->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( [ $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 = [ 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 = [ 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 = [ 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/www/wiki/includes/page/ImagePage.php b/www/wiki/includes/page/ImagePage.php
new file mode 100644
index 00000000..b5ff8059
--- /dev/null
+++ b/www/wiki/includes/page/ImagePage.php
@@ -0,0 +1,1229 @@
+<?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
+ */
+
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * 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;
+
+ /**
+ * @var WikiFilePage
+ */
+ protected $mPage;
+
+ /**
+ * @param Title $title
+ * @return WikiFilePage
+ */
+ protected function newPage( Title $title ) {
+ // Overload mPage with a file-specific page
+ return new WikiFilePage( $title );
+ }
+
+ /**
+ * @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;
+
+ Hooks::run( 'ImagePageFindFile', [ $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 ) {
+ $request->setVal( 'diffonly', 'true' );
+ }
+
+ parent::view();
+ return;
+ }
+
+ if ( $wgShowEXIF && $this->displayImg->exists() ) {
+ // @todo FIXME: Bad interface, see note on MediaHandler::formatMetadata().
+ $formattedMetadata = $this->displayImg->formatMetadata( $this->getContext() );
+ $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', [ '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 = $this->getContext()->msg( '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',
+ [ 'id' => 'filelinks' ],
+ $this->getContext()->msg( '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 = '';
+ Hooks::run( 'ImagePageAfterImageLinks', [ $this, &$html ] );
+ if ( $html ) {
+ $out->addHTML( $html );
+ }
+
+ if ( $showmeta ) {
+ $out->addHTML( Xml::element(
+ 'h2',
+ [ 'id' => 'metadata' ],
+ $this->getContext()->msg( 'metadata' )->text() ) . "\n" );
+ $out->addWikiText( $this->makeMetadataTable( $formattedMetadata ) );
+ $out->addModules( [ 'mediawiki.action.view.metadata' ] );
+ }
+
+ // Add remote Filepage.css
+ if ( !$this->repo->isLocal() ) {
+ $css = $this->repo->getDescriptionStylesheetUrl();
+ if ( $css ) {
+ $out->addStyle( $css );
+ }
+ }
+
+ $out->addModuleStyles( [
+ 'filepage', // always show the local local Filepage.css, T31277
+ 'mediawiki.action.view.filepage', // Add MediaWiki styles for a file page
+ ] );
+ }
+
+ /**
+ * @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 = [
+ '<li><a href="#file">' . $this->getContext()->msg( 'file-anchor-link' )->escaped() . '</a></li>',
+ '<li><a href="#filehistory">' . $this->getContext()->msg( 'filehist' )->escaped() . '</a></li>',
+ '<li><a href="#filelinks">' . $this->getContext()->msg( 'imagelinks' )->escaped() . '</a></li>',
+ ];
+
+ Hooks::run( 'ImagePageShowTOC', [ $this, &$r ] );
+
+ if ( $metadata ) {
+ $r[] = '<li><a href="#metadata">' .
+ $this->getContext()->msg( 'metadata' )->escaped() .
+ '</a></li>';
+ }
+
+ 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 .= $this->getContext()->msg( 'metadata-help' )->plain();
+ // Intial state is collapsed
+ // see filepage.css and mediawiki.action.view.metadata module.
+ $r .= "<table id=\"mw_metadata\" class=\"mw_metadata collapsed\">\n";
+ foreach ( $metadata as $type => $stuff ) {
+ foreach ( $stuff as $v ) {
+ $class = str_replace( ' ', '_', $v['id'] );
+ if ( $type == 'collapsed' ) {
+ $class .= ' mw-metadata-collapsible';
+ }
+ $r .= Html::rawElement( 'tr',
+ [ 'class' => $class ],
+ Html::rawElement( 'th', [], $v['name'] )
+ . Html::rawElement( 'td', [], $v['value'] )
+ );
+ }
+ }
+ $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();
+ }
+
+ private function getLanguageForRendering( WebRequest $request, File $file ) {
+ $handler = $this->displayImg->getHandler();
+ if ( !$handler ) {
+ return null;
+ }
+
+ $requestLanguage = $request->getVal( 'lang' );
+ if ( !is_null( $requestLanguage ) ) {
+ if ( $handler->validateParam( 'lang', $requestLanguage ) ) {
+ return $requestLanguage;
+ }
+ }
+
+ return $handler->getDefaultRenderLanguage( $this->displayImg );
+ }
+
+ protected function openShowImage() {
+ global $wgEnableUploads, $wgSend404Code, $wgSVGMaxSize;
+
+ $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 = [];
+ $page = 1;
+ } else {
+ $params = [ 'page' => $page ];
+ }
+
+ $renderLang = $this->getLanguageForRendering( $request, $this->displayImg );
+ if ( !is_null( $renderLang ) ) {
+ $params['lang'] = $renderLang;
+ }
+
+ $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;
+
+ // Avoid PHP 7.1 warning from passing $this by reference
+ $imagePage = $this;
+
+ Hooks::run( 'ImageOpenShowImageInlineBefore', [ &$imagePage, &$out ] );
+
+ if ( $this->displayImg->allowInlineDisplay() ) {
+ # image
+ # "Download high res version" link below the image
+ # $msgsize = $this->getContext()->msg( '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 = $this->getContext()->msg( 'show-big-image' )->escaped();
+
+ $thumbSizes = $this->getThumbSizes( $width_orig, $height_orig );
+ # Generate thumbnails or thumbnail links as needed...
+ $otherSizes = [];
+ 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 limited by $wgSVGMaxSize big,
+ // so all thumbs less than or equal that are shown.
+ if ( ( ( $size[0] <= $width_orig && $size[1] <= $height_orig )
+ || ( $this->displayImg->isVectorized()
+ && max( $size[0], $size[1] ) <= $wgSVGMaxSize )
+ )
+ && $size[0] != $width && $size[1] != $height
+ ) {
+ $sizeLink = $this->makeSizeLink( $params, $size[0], $size[1] );
+ if ( $sizeLink ) {
+ $otherSizes[] = $sizeLink;
+ }
+ }
+ }
+ $otherSizes = array_unique( $otherSizes );
+
+ $sizeLinkBigImagePreview = $this->makeSizeLink( $params, $width, $height );
+ $msgsmall = $this->getThumbPrevText( $params, $sizeLinkBigImagePreview );
+ if ( count( $otherSizes ) ) {
+ $msgsmall .= ' ' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'mw-filepage-other-resolutions' ],
+ $this->getContext()->msg( '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 = $this->getContext()->msg( 'file-nohires' )->parse();
+ }
+
+ $params['width'] = $width;
+ $params['height'] = $height;
+ $thumbnail = $this->displayImg->transform( $params );
+ Linker::processResponsiveImages( $this->displayImg, $thumbnail, $params );
+
+ $anchorclose = Html::rawElement(
+ 'div',
+ [ '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 = [
+ '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 = $this->getContext()->msg( 'imgmultipageprev' )->text();
+ // on the client side, this link is generated in ajaxifyPageNavigation()
+ // in the mediawiki.page.image.pagination module
+ $link = Linker::linkKnown(
+ $this->getTitle(),
+ $label,
+ [],
+ [ 'page' => $page - 1 ]
+ );
+ $thumb1 = Linker::makeThumbLinkObj(
+ $this->getTitle(),
+ $this->displayImg,
+ $link,
+ $label,
+ 'none',
+ [ 'page' => $page - 1 ]
+ );
+ } else {
+ $thumb1 = '';
+ }
+
+ if ( $page < $count ) {
+ $label = $this->getContext()->msg( 'imgmultipagenext' )->text();
+ $link = Linker::linkKnown(
+ $this->getTitle(),
+ $label,
+ [],
+ [ 'page' => $page + 1 ]
+ );
+ $thumb2 = Linker::makeThumbLinkObj(
+ $this->getTitle(),
+ $this->displayImg,
+ $link,
+ $label,
+ 'none',
+ [ 'page' => $page + 1 ]
+ );
+ } else {
+ $thumb2 = '';
+ }
+
+ global $wgScript;
+
+ $formParams = [
+ 'name' => 'pageselector',
+ 'action' => $wgScript,
+ ];
+ $options = [];
+ for ( $i = 1; $i <= $count; $i++ ) {
+ $options[] = Xml::option( $lang->formatNum( $i ), $i, $i == $page );
+ }
+ $select = Xml::tags( 'select',
+ [ 'id' => 'pageselector', 'name' => 'page' ],
+ implode( "\n", $options ) );
+
+ $out->addHTML(
+ '</td><td><div class="multipageimagenavbox">' .
+ Xml::openElement( 'form', $formParams ) .
+ Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) .
+ $this->getContext()->msg( 'imgmultigoto' )->rawParams( $select )->parse() .
+ $this->getContext()->msg( 'word-separator' )->escaped() .
+ Xml::submitButton( $this->getContext()->msg( '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( [ 'file-link' => true ] ) .
+ "</div>\n" );
+ }
+
+ $longDesc = $this->getContext()->msg( 'parentheses', $this->displayImg->getLongDesc() )->text();
+
+ $handler = $this->displayImg->getHandler();
+
+ // If this is a filetype with potential issues, warn the user.
+ if ( $handler ) {
+ $warningConfig = $handler->getWarningConfig( $this->displayImg );
+
+ if ( $warningConfig !== null ) {
+ // The warning will be displayed via CSS and JavaScript.
+ // We just need to tell the client side what message to use.
+ $output = $this->getContext()->getOutput();
+ $output->addJsConfigVars( 'wgFileWarning', $warningConfig );
+ $output->addModules( $warningConfig['module'] );
+ $output->addModules( 'mediawiki.filewarning' );
+ }
+ }
+
+ $medialink = "[[Media:$filename|$linktext]]";
+
+ if ( !$this->displayImg->isSafeFile() ) {
+ $warning = $this->getContext()->msg( '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 T27277.
+ // phpcs:disable Generic.Files.LineLength
+ $out->addWikiText( <<<EOT
+<div class="fullMedia"><span class="dangerousLink">{$medialink}</span> $dirmark<span class="fileInfo">$longDesc</span></div>
+<div class="mediaWarning">$warning</div>
+EOT
+ );
+ // phpcs:enable
+ } else {
+ $out->addWikiText( <<<EOT
+<div class="fullMedia">{$medialink} {$dirmark}<span class="fileInfo">$longDesc</span>
+</div>
+EOT
+ );
+ }
+
+ $renderLangOptions = $this->displayImg->getAvailableLanguages();
+ if ( count( $renderLangOptions ) >= 1 ) {
+ $out->addHTML( $this->doRenderLangOpt( $renderLangOptions, $renderLang ) );
+ }
+
+ // 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() ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ # No article exists either
+ # Show deletion log to be consistent with normal articles
+ LogEventsList::showLogExtract(
+ $out,
+ [ 'delete', 'move', 'protect' ],
+ $this->getTitle()->getPrefixedText(),
+ '',
+ [ 'lim' => 10,
+ 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
+ 'showIfEmpty' => false,
+ 'msgKey' => [ 'moveddeleted-notice' ]
+ ]
+ );
+ }
+
+ if ( $wgEnableUploads && $user->isAllowed( 'upload' ) ) {
+ // Only show an upload link if the user can upload
+ $uploadTitle = SpecialPage::getTitleFor( 'Upload' );
+ $nofile = [
+ 'filepage-nofile-link',
+ $uploadTitle->getFullURL( [ '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 Article::showMissingArticle.
+ $request->response()->statusHeader( 404 );
+ }
+ }
+ $out->setFileVersion( $this->displayImg );
+ }
+
+ /**
+ * Make the text under the image to say what size preview
+ *
+ * @param array $params parameters for thumbnail
+ * @param string $sizeLinkBigImagePreview HTML for the current size
+ * @return string HTML output
+ */
+ protected function getThumbPrevText( $params, $sizeLinkBigImagePreview ) {
+ if ( $sizeLinkBigImagePreview ) {
+ // Show a different message of preview is different format from original.
+ $previewTypeDiffers = false;
+ $origExt = $thumbExt = $this->displayImg->getExtension();
+ if ( $this->displayImg->getHandler() ) {
+ $origMime = $this->displayImg->getMimeType();
+ $typeParams = $params;
+ $this->displayImg->getHandler()->normaliseParams( $this->displayImg, $typeParams );
+ list( $thumbExt, $thumbMime ) = $this->displayImg->getHandler()->getThumbType(
+ $origExt, $origMime, $typeParams );
+ if ( $thumbMime !== $origMime ) {
+ $previewTypeDiffers = true;
+ }
+ }
+ if ( $previewTypeDiffers ) {
+ return $this->getContext()->msg( 'show-big-image-preview-differ' )->
+ rawParams( $sizeLinkBigImagePreview )->
+ params( strtoupper( $origExt ) )->
+ params( strtoupper( $thumbExt ) )->
+ parse();
+ } else {
+ return $this->getContext()->msg( 'show-big-image-preview' )->
+ rawParams( $sizeLinkBigImagePreview )->
+ parse();
+ }
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * 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
+ */
+ protected function makeSizeLink( $params, $width, $height ) {
+ $params['width'] = $width;
+ $params['height'] = $height;
+ $thumbnail = $this->displayImg->transform( $params );
+ if ( $thumbnail && !$thumbnail->isError() ) {
+ return Html::rawElement( 'a', [
+ 'href' => $thumbnail->getUrl(),
+ 'class' => 'mw-thumbnail-link'
+ ], $this->getContext()->msg( '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 &&
+ $this->getContext()->msg( 'sharedupload-desc-here' )->plain() !== '-'
+ ) {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-here', $repo, $descUrl ] );
+ } elseif ( $descUrl &&
+ $this->getContext()->msg( 'sharedupload-desc-there' )->plain() !== '-'
+ ) {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload-desc-there', $repo, $descUrl ] );
+ } else {
+ $out->wrapWikiMsg( $wrap, [ 'sharedupload', $repo ], ''/*BACKCOMPAT*/ );
+ }
+
+ if ( $descText ) {
+ $this->mExtraDescription = $descText;
+ }
+ }
+
+ public function getUploadUrl() {
+ $this->loadFile();
+ $uploadTitle = SpecialPage::getTitleFor( 'Upload' );
+ return $uploadTitle->getFullURL( [
+ '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()->quickUserCan( 'upload', $this->getContext()->getUser() );
+ if ( $canUpload && UploadBase::userCanReUpload(
+ $this->getContext()->getUser(),
+ $this->mPage->getFile() )
+ ) {
+ $ulink = Linker::makeExternalLink(
+ $this->getUploadUrl(),
+ $this->getContext()->msg( '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_REPLICA );
+
+ return $dbr->select(
+ [ 'imagelinks', 'page' ],
+ [ 'page_namespace', 'page_title', 'il_to' ],
+ [ 'il_to' => $target, 'il_from = page_id' ],
+ __METHOD__,
+ [ 'LIMIT' => $limit + 1, 'ORDER BY' => 'il_from', ]
+ );
+ }
+
+ protected function imageLinks() {
+ $limit = 100;
+
+ $out = $this->getContext()->getOutput();
+
+ $rows = [];
+ $redirects = [];
+ foreach ( $this->getTitle()->getRedirectsHere( NS_FILE ) as $redir ) {
+ $redirects[$redir->getDBkey()] = [];
+ $rows[] = (object)[
+ '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',
+ [ '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',
+ [ 'class' => 'mw-imagepage-linkstoimage' ] ) . "\n"
+ );
+ $count = 0;
+
+ // Sort the list by namespace:title
+ usort( $rows, [ $this, 'compare' ] );
+
+ // Create links for every element
+ $currentCount = 0;
+ foreach ( $rows as $element ) {
+ $currentCount++;
+ if ( $currentCount > $limit ) {
+ break;
+ }
+
+ $query = [];
+ # 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, [], $query
+ );
+ if ( !isset( $redirects[$element->page_title] ) ) {
+ # No redirects
+ $liContents = $link;
+ } elseif ( count( $redirects[$element->page_title] ) === 0 ) {
+ # Redirect without usages
+ $liContents = $this->getContext()->msg( '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',
+ [ 'class' => 'mw-imagepage-linkstoimage-ns' . $element->page_namespace ],
+ $link2
+ ) . "\n";
+ }
+
+ $ul = Html::rawElement(
+ 'ul',
+ [ 'class' => 'mw-imagepage-redirectstofile' ],
+ $li
+ ) . "\n";
+ $liContents = $this->getContext()->msg( 'linkstoimage-redirect' )->rawParams(
+ $link, $ul )->parse();
+ }
+ $out->addHTML( Html::rawElement(
+ 'li',
+ [ '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 = $this->getContext()->msg(
+ 'shared-repo-from',
+ $file->getRepo()->getDisplayName()
+ )->escaped();
+ }
+ $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( $this->getContext()->msg( '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]
+ : [ 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 $renderLang Language code for the language we want the file to rendered in.
+ * @return string HTML to insert underneath image.
+ */
+ protected function doRenderLangOpt( array $langChoices, $renderLang ) {
+ global $wgScript;
+ $opts = '';
+
+ $matchedRenderLang = $this->displayImg->getMatchedLanguage( $renderLang );
+
+ foreach ( $langChoices as $lang ) {
+ $opts .= $this->createXmlOptionStringForLanguage(
+ $lang,
+ $matchedRenderLang === $lang
+ );
+ }
+
+ // Allow for the default case in an svg <switch> that is displayed if no
+ // systemLanguage attribute matches
+ $opts .= "\n" .
+ Xml::option(
+ $this->getContext()->msg( 'img-lang-default' )->text(),
+ 'und',
+ is_null( $matchedRenderLang )
+ );
+
+ $select = Html::rawElement(
+ 'select',
+ [ 'id' => 'mw-imglangselector', 'name' => 'lang' ],
+ $opts
+ );
+ $submit = Xml::submitButton( $this->getContext()->msg( 'img-lang-go' )->text() );
+
+ $formContents = $this->getContext()->msg( 'img-lang-info' )
+ ->rawParams( $select, $submit )
+ ->parse();
+ $formContents .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() );
+
+ $langSelectLine = Html::rawElement( 'div', [ 'id' => 'mw-imglangselector-line' ],
+ Html::rawElement( 'form', [ 'action' => $wgScript ], $formContents )
+ );
+ return $langSelectLine;
+ }
+
+ /**
+ * @param $lang string
+ * @param $selected bool
+ * @return string
+ */
+ private function createXmlOptionStringForLanguage( $lang, $selected ) {
+ $code = LanguageCode::bcp47( $lang );
+ $name = Language::fetchLanguageName( $code, $this->getContext()->getLanguage()->getCode() );
+ if ( $name !== '' ) {
+ $display = $this->getContext()->msg( 'img-lang-opt', $code, $name )->text();
+ } else {
+ $display = $code;
+ }
+ return "\n" .
+ Xml::option(
+ $display,
+ $lang,
+ $selected
+ );
+ }
+
+ /**
+ * 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 [ 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 [ $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[] = [ $origWidth, $origHeight ];
+ } else {
+ # Creating thumb links triggers thumbnail generation.
+ # Just generate the thumb for the current users prefs.
+ $thumbSizes = [
+ $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[] = [ $origWidth, $origHeight ];
+ }
+ }
+ return $thumbSizes;
+ }
+
+ /**
+ * @see WikiFilePage::getFile
+ * @return bool|File
+ */
+ public function getFile() {
+ return $this->mPage->getFile();
+ }
+
+ /**
+ * @see WikiFilePage::isLocal
+ * @return bool
+ */
+ public function isLocal() {
+ return $this->mPage->isLocal();
+ }
+
+ /**
+ * @see WikiFilePage::getDuplicates
+ * @return array|null
+ */
+ public function getDuplicates() {
+ return $this->mPage->getDuplicates();
+ }
+
+ /**
+ * @see WikiFilePage::getForeignCategories
+ * @return TitleArray|Title[]
+ */
+ public function getForeignCategories() {
+ $this->mPage->getForeignCategories();
+ }
+
+}
diff --git a/www/wiki/includes/page/Page.php b/www/wiki/includes/page/Page.php
new file mode 100644
index 00000000..2cb1fc03
--- /dev/null
+++ b/www/wiki/includes/page/Page.php
@@ -0,0 +1,25 @@
+<?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
+ */
+
+/**
+ * Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage)
+ */
+interface Page {
+}
diff --git a/www/wiki/includes/page/PageArchive.php b/www/wiki/includes/page/PageArchive.php
new file mode 100644
index 00000000..8b42020a
--- /dev/null
+++ b/www/wiki/includes/page/PageArchive.php
@@ -0,0 +1,752 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Used to show archived pages and eventually restore them.
+ */
+class PageArchive {
+ /** @var Title */
+ protected $title;
+
+ /** @var Status */
+ protected $fileStatus;
+
+ /** @var Status */
+ protected $revisionStatus;
+
+ /** @var Config */
+ protected $config;
+
+ public function __construct( $title, Config $config = null ) {
+ if ( is_null( $title ) ) {
+ throw new MWException( __METHOD__ . ' given a null title.' );
+ }
+ $this->title = $title;
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' did not have a Config object passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ $this->config = $config;
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * List all deleted pages recorded in the archive table. Returns result
+ * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
+ * namespace/title.
+ *
+ * @return ResultWrapper
+ */
+ public static function listAllPages() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ return self::listPages( $dbr, '' );
+ }
+
+ /**
+ * List deleted pages recorded in the archive matching the
+ * given term, using search engine archive.
+ * Returns result wrapper with (ar_namespace, ar_title, count) fields.
+ *
+ * @param string $term Search term
+ * @return ResultWrapper
+ */
+ public static function listPagesBySearch( $term ) {
+ $title = Title::newFromText( $term );
+ if ( $title ) {
+ $ns = $title->getNamespace();
+ $termMain = $title->getText();
+ $termDb = $title->getDBkey();
+ } else {
+ // Prolly won't work too good
+ // @todo handle bare namespace names cleanly?
+ $ns = 0;
+ $termMain = $termDb = $term;
+ }
+
+ // Try search engine first
+ $engine = MediaWikiServices::getInstance()->newSearchEngine();
+ $engine->setLimitOffset( 100 );
+ $engine->setNamespaces( [ $ns ] );
+ $results = $engine->searchArchiveTitle( $termMain );
+ if ( !$results->isOK() ) {
+ $results = [];
+ } else {
+ $results = $results->getValue();
+ }
+
+ if ( !$results ) {
+ // Fall back to regular prefix search
+ return self::listPagesByPrefix( $term );
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $condTitles = array_unique( array_map( function ( Title $t ) {
+ return $t->getDBkey();
+ }, $results ) );
+ $conds = [
+ 'ar_namespace' => $ns,
+ $dbr->makeList( [ 'ar_title' => $condTitles ], LIST_OR ) . " OR ar_title " .
+ $dbr->buildLike( $termDb, $dbr->anyString() )
+ ];
+
+ return self::listPages( $dbr, $conds );
+ }
+
+ /**
+ * List deleted pages recorded in the archive table matching the
+ * given title prefix.
+ * Returns result wrapper with (ar_namespace, ar_title, count) fields.
+ *
+ * @param string $prefix Title prefix
+ * @return ResultWrapper
+ */
+ public static function listPagesByPrefix( $prefix ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $title = Title::newFromText( $prefix );
+ if ( $title ) {
+ $ns = $title->getNamespace();
+ $prefix = $title->getDBkey();
+ } else {
+ // Prolly won't work too good
+ // @todo handle bare namespace names cleanly?
+ $ns = 0;
+ }
+
+ $conds = [
+ 'ar_namespace' => $ns,
+ 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
+ ];
+
+ return self::listPages( $dbr, $conds );
+ }
+
+ /**
+ * @param IDatabase $dbr
+ * @param string|array $condition
+ * @return bool|ResultWrapper
+ */
+ protected static function listPages( $dbr, $condition ) {
+ return $dbr->select(
+ [ 'archive' ],
+ [
+ 'ar_namespace',
+ 'ar_title',
+ 'count' => 'COUNT(*)'
+ ],
+ $condition,
+ __METHOD__,
+ [
+ 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
+ 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
+ 'LIMIT' => 100,
+ ]
+ );
+ }
+
+ /**
+ * List the revisions of the given page. Returns result wrapper with
+ * various archive table fields.
+ *
+ * @return ResultWrapper
+ */
+ public function listRevisions() {
+ $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $revisionStore->getArchiveQueryInfo();
+
+ $conds = [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ ];
+ $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
+
+ ChangeTags::modifyDisplayQuery(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $conds,
+ $queryInfo['joins'],
+ $options,
+ ''
+ );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ return $dbr->select(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $conds,
+ __METHOD__,
+ $options,
+ $queryInfo['joins']
+ );
+ }
+
+ /**
+ * List the deleted file revisions for this page, if it's a file page.
+ * Returns a result wrapper with various filearchive fields, or null
+ * if not a file page.
+ *
+ * @return ResultWrapper
+ * @todo Does this belong in Image for fuller encapsulation?
+ */
+ public function listFiles() {
+ if ( $this->title->getNamespace() != NS_FILE ) {
+ return null;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $fileQuery = ArchivedFile::getQueryInfo();
+ return $dbr->select(
+ $fileQuery['tables'],
+ $fileQuery['fields'],
+ [ 'fa_name' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_timestamp DESC' ],
+ $fileQuery['joins']
+ );
+ }
+
+ /**
+ * Return a Revision object containing data for the deleted revision.
+ * Note that the result *may* or *may not* have a null page ID.
+ *
+ * @param string $timestamp
+ * @return Revision|null
+ */
+ public function getRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $arQuery = Revision::getArchiveQueryInfo();
+
+ $row = $dbr->selectRow(
+ $arQuery['tables'],
+ $arQuery['fields'],
+ [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp' => $dbr->timestamp( $timestamp )
+ ],
+ __METHOD__,
+ [],
+ $arQuery['joins']
+ );
+
+ if ( $row ) {
+ return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] );
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the most-previous revision, either live or deleted, against
+ * the deleted revision given by timestamp.
+ *
+ * May produce unexpected results in case of history merges or other
+ * unusual time issues.
+ *
+ * @param string $timestamp
+ * @return Revision|null Null when there is no previous revision
+ */
+ public function getPreviousRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Check the previous deleted revision...
+ $row = $dbr->selectRow( 'archive',
+ 'ar_timestamp',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'ar_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
+
+ $row = $dbr->selectRow( [ 'page', 'revision' ],
+ [ 'rev_id', 'rev_timestamp' ],
+ [
+ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey(),
+ 'page_id = rev_page',
+ 'rev_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rev_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
+ $prevLiveId = $row ? intval( $row->rev_id ) : null;
+
+ if ( $prevLive && $prevLive > $prevDeleted ) {
+ // Most prior revision was live
+ return Revision::newFromId( $prevLiveId );
+ } elseif ( $prevDeleted ) {
+ // Most prior revision was deleted
+ return $this->getRevision( $prevDeleted );
+ }
+
+ // No prior revision on this page.
+ return null;
+ }
+
+ /**
+ * Get the text from an archive row containing ar_text_id
+ *
+ * @deprecated since 1.31
+ * @param object $row Database row
+ * @return string
+ */
+ public function getTextFromRow( $row ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $text = $dbr->selectRow( 'text',
+ [ 'old_text', 'old_flags' ],
+ [ 'old_id' => $row->ar_text_id ],
+ __METHOD__ );
+
+ return Revision::getRevisionText( $text );
+ }
+
+ /**
+ * Fetch (and decompress if necessary) the stored text of the most
+ * recently edited deleted revision of the page.
+ *
+ * If there are no archived revisions for the page, returns NULL.
+ *
+ * @return string|null
+ */
+ public function getLastRevisionText() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow(
+ [ 'archive', 'text' ],
+ [ 'old_text', 'old_flags' ],
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ],
+ [ 'text' => [ 'JOIN', 'old_id = ar_text_id' ] ]
+ );
+
+ if ( $row ) {
+ return Revision::getRevisionText( $row );
+ }
+
+ return null;
+ }
+
+ /**
+ * Quick check if any archived revisions are present for the page.
+ *
+ * @return bool
+ */
+ public function isDeleted() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__
+ );
+
+ return ( $n > 0 );
+ }
+
+ /**
+ * Restore the given (or all) text and file revisions for the page.
+ * Once restored, the items will be removed from the archive tables.
+ * The deletion log will be updated with an undeletion notice.
+ *
+ * This also sets Status objects, $this->fileStatus and $this->revisionStatus
+ * (depending what operations are attempted).
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param string $comment
+ * @param array $fileVersions
+ * @param bool $unsuppress
+ * @param User $user User performing the action, or null to use $wgUser
+ * @param string|string[] $tags Change tags to add to log entry
+ * ($user should be able to add the specified tags before this is called)
+ * @return array|bool array(number of file revisions restored, number of image revisions
+ * restored, log message) on success, false on failure.
+ */
+ public function undelete( $timestamps, $comment = '', $fileVersions = [],
+ $unsuppress = false, User $user = null, $tags = null
+ ) {
+ // If both the set of text revisions and file revisions are empty,
+ // restore everything. Otherwise, just restore the requested items.
+ $restoreAll = empty( $timestamps ) && empty( $fileVersions );
+
+ $restoreText = $restoreAll || !empty( $timestamps );
+ $restoreFiles = $restoreAll || !empty( $fileVersions );
+
+ if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) {
+ $img = wfLocalFile( $this->title );
+ $img->load( File::READ_LATEST );
+ $this->fileStatus = $img->restore( $fileVersions, $unsuppress );
+ if ( !$this->fileStatus->isOK() ) {
+ return false;
+ }
+ $filesRestored = $this->fileStatus->successCount;
+ } else {
+ $filesRestored = 0;
+ }
+
+ if ( $restoreText ) {
+ $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
+ if ( !$this->revisionStatus->isOK() ) {
+ return false;
+ }
+
+ $textRestored = $this->revisionStatus->getValue();
+ } else {
+ $textRestored = 0;
+ }
+
+ // Touch the log!
+
+ if ( !$textRestored && !$filesRestored ) {
+ wfDebug( "Undelete: nothing undeleted...\n" );
+
+ return false;
+ }
+
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $logEntry = new ManualLogEntry( 'delete', 'restore' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $this->title );
+ $logEntry->setComment( $comment );
+ $logEntry->setTags( $tags );
+ $logEntry->setParameters( [
+ ':assoc:count' => [
+ 'revisions' => $textRestored,
+ 'files' => $filesRestored,
+ ],
+ ] );
+
+ Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
+
+ $logid = $logEntry->insert();
+ $logEntry->publish( $logid );
+
+ return [ $textRestored, $filesRestored, $comment ];
+ }
+
+ /**
+ * This is the meaty bit -- It restores archived revisions of the given page
+ * to the revision table.
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs
+ * @param string $comment
+ * @throws ReadOnlyError
+ * @return Status Status object containing the number of revisions restored on success
+ */
+ private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError();
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $restoreAll = empty( $timestamps );
+
+ # Does this page already exist? We'll have to update it...
+ $article = WikiPage::factory( $this->title );
+ # Load latest data for the current page (T33179)
+ $article->loadPageData( 'fromdbmaster' );
+ $oldcountable = $article->isCountable();
+
+ $page = $dbw->selectRow( 'page',
+ [ 'page_id', 'page_latest' ],
+ [ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ] // lock page
+ );
+
+ if ( $page ) {
+ $makepage = false;
+ # Page already exists. Import the history, and if necessary
+ # we'll update the latest revision field in the record.
+
+ # Get the time span of this page
+ $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
+ [ 'rev_id' => $page->page_latest ],
+ __METHOD__ );
+
+ if ( $previousTimestamp === false ) {
+ wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( 'undeleterevision-missing' );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ } else {
+ # Have to create a new article...
+ $makepage = true;
+ $previousTimestamp = 0;
+ }
+
+ $oldWhere = [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ ];
+ if ( !$restoreAll ) {
+ $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
+ }
+
+ $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $revisionStore->getArchiveQueryInfo();
+ $queryInfo['tables'][] = 'revision';
+ $queryInfo['fields'][] = 'rev_id';
+ $queryInfo['joins']['revision'] = [ 'LEFT JOIN', 'ar_rev_id=rev_id' ];
+
+ /**
+ * Select each archived revision...
+ */
+ $result = $dbw->select(
+ $queryInfo['tables'],
+ $queryInfo['fields'],
+ $oldWhere,
+ __METHOD__,
+ /* options */
+ [ 'ORDER BY' => 'ar_timestamp' ],
+ $queryInfo['joins']
+ );
+
+ $rev_count = $result->numRows();
+ if ( !$rev_count ) {
+ wfDebug( __METHOD__ . ": no revisions to restore\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( "undelete-no-results" );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ // We use ar_id because there can be duplicate ar_rev_id even for the same
+ // page. In this case, we may be able to restore the first one.
+ $restoreFailedArIds = [];
+
+ // Map rev_id to the ar_id that is allowed to use it. When checking later,
+ // if it doesn't match, the current ar_id can not be restored.
+
+ // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
+ // rev_id is taken before we even start the restore).
+ $allowedRevIdToArIdMap = [];
+
+ $latestRestorableRow = null;
+
+ foreach ( $result as $row ) {
+ if ( $row->ar_rev_id ) {
+ // rev_id is taken even before we start restoring.
+ if ( $row->ar_rev_id === $row->rev_id ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
+ } else {
+ // rev_id is not taken yet in the DB, but it might be taken
+ // by a prior revision in the same restore operation. If
+ // not, we need to reserve it.
+ if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ } else {
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
+ $latestRestorableRow = $row;
+ }
+ }
+ } else {
+ // If ar_rev_id is null, there can't be a collision, and a
+ // rev_id will be chosen automatically.
+ $latestRestorableRow = $row;
+ }
+ }
+
+ $result->seek( 0 ); // move back
+
+ $oldPageId = 0;
+ if ( $latestRestorableRow !== null ) {
+ $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
+
+ // grab the content to check consistency with global state before restoring the page.
+ $revision = Revision::newFromArchiveRow( $latestRestorableRow,
+ [
+ 'title' => $article->getTitle(), // used to derive default content model
+ ]
+ );
+ $user = User::newFromName( $revision->getUserText( Revision::RAW ), false );
+ $content = $revision->getContent( Revision::RAW );
+
+ // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
+ $status = $content->prepareSave( $article, 0, -1, $user );
+ if ( !$status->isOK() ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ }
+
+ $newid = false; // newly created page ID
+ $restored = 0; // number of revisions restored
+ /** @var Revision $revision */
+ $revision = null;
+ $restoredPages = [];
+ // If there are no restorable revisions, we can skip most of the steps.
+ if ( $latestRestorableRow === null ) {
+ $failedRevisionCount = $rev_count;
+ } else {
+ if ( $makepage ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ // Safe to insert now...
+ $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id );
+ if ( $newid === false ) {
+ // The old ID is reserved; let's pick another
+ $newid = $article->insertOn( $dbw );
+ }
+ $pageId = $newid;
+ } else {
+ // Check if a deleted revision will become the current revision...
+ if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ }
+
+ $newid = false;
+ $pageId = $article->getId();
+ }
+
+ foreach ( $result as $row ) {
+ // Check for key dupes due to needed archive integrity.
+ if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
+ continue;
+ }
+ // Insert one revision at a time...maintaining deletion status
+ // unless we are specifically removing all restrictions...
+ $revision = Revision::newFromArchiveRow( $row,
+ [
+ 'page' => $pageId,
+ 'title' => $this->title,
+ 'deleted' => $unsuppress ? 0 : $row->ar_deleted
+ ] );
+
+ // This will also copy the revision to ip_changes if it was an IP edit.
+ $revision->insertOn( $dbw );
+
+ $restored++;
+
+ Hooks::run( 'ArticleRevisionUndeleted',
+ [ &$this->title, $revision, $row->ar_page_id ] );
+ $restoredPages[$row->ar_page_id] = true;
+ }
+
+ // Now that it's safely stored, take it out of the archive
+ // Don't delete rows that we failed to restore
+ $toDeleteConds = $oldWhere;
+ $failedRevisionCount = count( $restoreFailedArIds );
+ if ( $failedRevisionCount > 0 ) {
+ $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
+ }
+
+ $dbw->delete( 'archive',
+ $toDeleteConds,
+ __METHOD__ );
+ }
+
+ $status = Status::newGood( $restored );
+
+ if ( $failedRevisionCount > 0 ) {
+ $status->warning(
+ wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
+ }
+
+ // Was anything restored at all?
+ if ( $restored ) {
+ $created = (bool)$newid;
+ // Attach the latest revision to the page...
+ $wasnew = $article->updateIfNewerOn( $dbw, $revision );
+ if ( $created || $wasnew ) {
+ // Update site stats, link tables, etc
+ $article->doEditUpdates(
+ $revision,
+ User::newFromName( $revision->getUserText( Revision::RAW ), false ),
+ [
+ 'created' => $created,
+ 'oldcountable' => $oldcountable,
+ 'restored' => true
+ ]
+ );
+ }
+
+ Hooks::run( 'ArticleUndelete',
+ [ &$this->title, $created, $comment, $oldPageId, $restoredPages ] );
+ if ( $this->title->getNamespace() == NS_FILE ) {
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $this->title, 'imagelinks', 'file-restore' )
+ );
+ }
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getFileStatus() {
+ return $this->fileStatus;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getRevisionStatus() {
+ return $this->revisionStatus;
+ }
+}
diff --git a/www/wiki/includes/page/WikiCategoryPage.php b/www/wiki/includes/page/WikiCategoryPage.php
new file mode 100644
index 00000000..6c932029
--- /dev/null
+++ b/www/wiki/includes/page/WikiCategoryPage.php
@@ -0,0 +1,64 @@
+<?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;
+ }
+
+ /**
+ * Checks if a category is hidden.
+ *
+ * @since 1.27
+ *
+ * @return bool
+ */
+ public function isHidden() {
+ $pageId = $this->getTitle()->getArticleID();
+ $pageProps = PageProps::getInstance()->getProperties( $this->getTitle(), 'hiddencat' );
+
+ return isset( $pageProps[$pageId] ) ? true : false;
+ }
+}
diff --git a/www/wiki/includes/page/WikiFilePage.php b/www/wiki/includes/page/WikiFilePage.php
new file mode 100644
index 00000000..4c2ebdc2
--- /dev/null
+++ b/www/wiki/includes/page/WikiFilePage.php
@@ -0,0 +1,259 @@
+<?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
+ */
+
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * Special handling for file pages
+ *
+ * @ingroup Media
+ */
+class WikiFilePage extends WikiPage {
+ /** @var File */
+ protected $mFile = false;
+ /** @var LocalRepo */
+ protected $mRepo = null;
+ /** @var bool */
+ protected $mFileLoaded = false;
+ /** @var array */
+ 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 = [];
+ 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" );
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $this->mTitle, 'imagelinks', 'file-purge' )
+ );
+ } 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)
+
+ // Purge current version and its thumbnails
+ $this->mFile->purgeCache( [ 'forThumbRefresh' => true ] );
+
+ // Purge the old versions and their thumbnails
+ foreach ( $this->mFile->getHistory() as $oldFile ) {
+ $oldFile->purgeCache( [ '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( [] ) );
+ }
+
+ /** @var LocalRepo $repo */
+ $repo = $file->getRepo();
+ $dbr = $repo->getReplicaDB();
+
+ $res = $dbr->select(
+ [ 'page', 'categorylinks' ],
+ [
+ 'page_title' => 'cl_to',
+ 'page_namespace' => NS_CATEGORY,
+ ],
+ [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey(),
+ ],
+ __METHOD__,
+ [],
+ [ 'categorylinks' => [ 'INNER JOIN', 'page_id = cl_from' ] ]
+ );
+
+ return TitleArray::newFromResult( $res );
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getWikiDisplayName() {
+ return $this->getFile()->getRepo()->getDisplayName();
+ }
+
+ /**
+ * @since 1.28
+ * @return string
+ */
+ public function getSourceURL() {
+ return $this->getFile()->getDescriptionUrl();
+ }
+}
diff --git a/www/wiki/includes/page/WikiPage.php b/www/wiki/includes/page/WikiPage.php
new file mode 100644
index 00000000..820df58d
--- /dev/null
+++ b/www/wiki/includes/page/WikiPage.php
@@ -0,0 +1,3768 @@
+<?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
+ */
+
+use MediaWiki\Edit\PreparedEdit;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBUnexpectedError;
+
+/**
+ * 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.
+ */
+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 PreparedEdit 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';
+
+ /**
+ * Constructor and clear the article
+ * @param Title $title Reference to a Title object.
+ */
+ public function __construct( Title $title ) {
+ $this->mTitle = $title;
+ }
+
+ /**
+ * Makes sure that the mTitle object is cloned
+ * to the newly cloned WikiPage.
+ */
+ public function __clone() {
+ $this->mTitle = clone $this->mTitle;
+ }
+
+ /**
+ * Create a WikiPage object of the appropriate class for the given title.
+ *
+ * @param Title $title
+ *
+ * @throws MWException
+ * @return WikiPage|WikiCategoryPage|WikiFilePage
+ */
+ 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." );
+ }
+
+ $page = null;
+ if ( !Hooks::run( 'WikiPageFactory', [ $title, &$page ] ) ) {
+ return $page;
+ }
+
+ 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 replica DB
+ * - "fromdbmaster" or WikiPage::READ_LATEST to select from the master database
+ *
+ * @return WikiPage|null
+ */
+ public static function newFromID( $id, $from = 'fromdb' ) {
+ // page ids are never 0 or negative, see T63166
+ if ( $id < 1 ) {
+ return null;
+ }
+
+ $from = self::convertSelectType( $from );
+ $db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_REPLICA );
+ $pageQuery = self::getQueryInfo();
+ $row = $db->selectRow(
+ $pageQuery['tables'], $pageQuery['fields'], [ 'page_id' => $id ], __METHOD__,
+ [], $pageQuery['joins']
+ );
+ 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 replica 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;
+ }
+ }
+
+ /**
+ * @todo Move this UI stuff somewhere else
+ *
+ * @see ContentHandler::getActionOverrides
+ * @return array
+ */
+ public function getActionOverrides() {
+ return $this->getContentHandler()->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->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;
+ // T59026: 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.
+ *
+ * @deprecated since 1.31, use self::getQueryInfo() instead.
+ * @return array
+ */
+ public static function selectFields() {
+ global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+
+ wfDeprecated( __METHOD__, '1.31' );
+
+ $fields = [
+ 'page_id',
+ 'page_namespace',
+ 'page_title',
+ 'page_restrictions',
+ '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;
+ }
+
+ /**
+ * Return the tables, fields, and join conditions to be selected to create
+ * a new page object.
+ * @since 1.31
+ * @return array With three keys:
+ * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+ * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+ * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+ */
+ public static function getQueryInfo() {
+ global $wgContentHandlerUseDB, $wgPageLanguageUseDB;
+
+ $ret = [
+ 'tables' => [ 'page' ],
+ 'fields' => [
+ 'page_id',
+ 'page_namespace',
+ 'page_title',
+ 'page_restrictions',
+ 'page_is_redirect',
+ 'page_is_new',
+ 'page_random',
+ 'page_touched',
+ 'page_links_updated',
+ 'page_latest',
+ 'page_len',
+ ],
+ 'joins' => [],
+ ];
+
+ if ( $wgContentHandlerUseDB ) {
+ $ret['fields'][] = 'page_content_model';
+ }
+
+ if ( $wgPageLanguageUseDB ) {
+ $ret['fields'][] = 'page_lang';
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Fetch a page record with the given conditions
+ * @param IDatabase $dbr
+ * @param array $conditions
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ protected function pageData( $dbr, $conditions, $options = [] ) {
+ $pageQuery = self::getQueryInfo();
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'ArticlePageDataBefore', [
+ &$wikiPage, &$pageQuery['fields'], &$pageQuery['tables'], &$pageQuery['joins']
+ ] );
+
+ $row = $dbr->selectRow(
+ $pageQuery['tables'],
+ $pageQuery['fields'],
+ $conditions,
+ __METHOD__,
+ $options,
+ $pageQuery['joins']
+ );
+
+ Hooks::run( 'ArticlePageDataAfter', [ &$wikiPage, &$row ] );
+
+ return $row;
+ }
+
+ /**
+ * Fetch a page record matching the Title object's namespace and title
+ * using a sanitized title string
+ *
+ * @param IDatabase $dbr
+ * @param Title $title
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ public function pageDataFromTitle( $dbr, $title, $options = [] ) {
+ return $this->pageData( $dbr, [
+ 'page_namespace' => $title->getNamespace(),
+ 'page_title' => $title->getDBkey() ], $options );
+ }
+
+ /**
+ * Fetch a page record matching the requested ID
+ *
+ * @param IDatabase $dbr
+ * @param int $id
+ * @param array $options
+ * @return object|bool Database result resource, or false on failure
+ */
+ public function pageDataFromId( $dbr, $id, $options = [] ) {
+ return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
+ }
+
+ /**
+ * Load the object from a given source by title
+ *
+ * @param object|string|int $from One of the following:
+ * - A DB query result object.
+ * - "fromdb" or WikiPage::READ_NORMAL to get from a replica 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 ( is_int( $from ) ) {
+ list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
+ $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $db = $loadBalancer->getConnection( $index );
+ $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
+
+ if ( !$data
+ && $index == DB_REPLICA
+ && $loadBalancer->getServerCount() > 1
+ && $loadBalancer->hasOrMadeRecentMasterChanges()
+ ) {
+ $from = self::READ_LATEST;
+ list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
+ $db = $loadBalancer->getConnection( $index );
+ $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
+ }
+ } else {
+ // No idea from where the caller got this data, assume replica DB.
+ $data = $from;
+ $from = self::READ_NORMAL;
+ }
+
+ $this->loadFromRow( $data, $from );
+ }
+
+ /**
+ * Load the object from a database row
+ *
+ * @since 1.20
+ * @param object|bool $data DB row containing fields returned by selectFields() or false
+ * @param string|int $from One of the following:
+ * - "fromdb" or WikiPage::READ_NORMAL if the data comes from a replica DB
+ * - "fromdbmaster" or WikiPage::READ_LATEST if the data comes from the master DB
+ * - "forupdate" or WikiPage::READ_LOCKING if the data comes 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->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 );
+ // T39225: $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->mTitle->isKnown();
+ }
+
+ /**
+ * Tests if the article content represents a redirect
+ *
+ * @return bool
+ */
+ public function isRedirect() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+
+ return (bool)$this->mIsRedirect;
+ }
+
+ /**
+ * 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() ) {
+ $cache = ObjectCache::getMainWANInstance();
+
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'page-content-model', $this->getLatest() ),
+ $cache::TTL_MONTH,
+ function () {
+ $rev = $this->getRevision();
+ if ( $rev ) {
+ // Look at the revision's actual content model
+ return $rev->getContentModel();
+ } else {
+ $title = $this->mTitle->getPrefixedDBkey();
+ wfWarn( "Page $title exists but has no (visible) revisions!" );
+ return $this->mTitle->getContentModel();
+ }
+ }
+ );
+ }
+
+ // 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 this page exists and is not a redirect
+ */
+ public function checkTouched() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return ( $this->mId && !$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() {
+ // Try using the replica DB first, then try the master
+ $rev = $this->mTitle->getFirstRevision();
+ if ( !$rev ) {
+ $rev = $this->mTitle->getFirstRevision( Title::GAID_FOR_UPDATE );
+ }
+ return $rev;
+ }
+
+ /**
+ * 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
+ }
+
+ if ( $this->mDataLoadedFrom == self::READ_LOCKING ) {
+ // T39225: 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.
+ // https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read
+ $flags = Revision::READ_LOCKING;
+ $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
+ } elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) {
+ // Bug T93976: if page_latest was loaded from the master, fetch the
+ // revision from there as well, as it may not exist yet on a replica DB.
+ // Also, this keeps the queries in the same REPEATABLE-READ snapshot.
+ $flags = Revision::READ_LATEST;
+ $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
+ } else {
+ $dbr = wfGetDB( DB_REPLICA );
+ $revision = Revision::newKnownCurrent( $dbr, $this->getTitle(), $latest );
+ }
+
+ 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;
+ }
+
+ /**
+ * @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;
+ }
+ }
+
+ /**
+ * 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 PreparedEdit|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_REPLICA )->selectField( 'pagelinks', 1,
+ [ '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_REPLICA );
+ $row = $dbr->selectRow( 'redirect',
+ [ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
+ [ '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 if the content is a redirect
+ *
+ * The database update will be deferred via DeferredUpdates
+ *
+ * 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() {
+ $content = $this->getContent();
+ $retval = $content ? $content->getUltimateRedirectTarget() : null;
+ if ( !$retval ) {
+ return null;
+ }
+
+ // Update the DB post-send if the page has not cached since now
+ $latest = $this->getLatest();
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $retval, $latest ) {
+ $this->insertRedirectEntry( $retval, $latest );
+ },
+ DeferredUpdates::POSTSEND,
+ wfGetDB( DB_MASTER )
+ );
+
+ return $retval;
+ }
+
+ /**
+ * Insert or update the redirect table entry for this page to indicate it redirects to $rt
+ * @param Title $rt Redirect target
+ * @param int|null $oldLatest Prior page_latest for check and set
+ */
+ public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ if ( !$oldLatest || $oldLatest == $this->lockAndGetLatest() ) {
+ $dbw->upsert(
+ 'redirect',
+ [
+ 'rd_from' => $this->getId(),
+ 'rd_namespace' => $rt->getNamespace(),
+ 'rd_title' => $rt->getDBkey(),
+ 'rd_fragment' => $rt->getFragment(),
+ 'rd_interwiki' => $rt->getInterwiki(),
+ ],
+ [ 'rd_from' ],
+ [
+ 'rd_namespace' => $rt->getNamespace(),
+ 'rd_title' => $rt->getDBkey(),
+ 'rd_fragment' => $rt->getFragment(),
+ 'rd_interwiki' => $rt->getInterwiki(),
+ ],
+ __METHOD__
+ );
+ }
+
+ $dbw->endAtomic( __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( [ 'rdfrom' => $source ] );
+ } else {
+ // External 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_REPLICA );
+
+ $actorMigration = ActorMigration::newMigration();
+ $actorQuery = $actorMigration->getJoin( 'rev_user' );
+
+ $tables = array_merge( [ 'revision' ], $actorQuery['tables'], [ 'user' ] );
+
+ $fields = [
+ 'user_id' => $actorQuery['fields']['rev_user'],
+ 'user_name' => $actorQuery['fields']['rev_user_text'],
+ 'actor_id' => $actorQuery['fields']['rev_actor'],
+ 'user_real_name' => 'MIN(user_real_name)',
+ 'timestamp' => 'MAX(rev_timestamp)',
+ ];
+
+ $conds = [ '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()
+ ? User::newFromId( $this->getUser() )
+ : User::newFromName( $this->getUserText(), false );
+ $conds[] = 'NOT(' . $actorMigration->getWhere( $dbr, 'rev_user', $user )['conds'] . ')';
+
+ // Username hidden?
+ $conds[] = "{$dbr->bitAnd( 'rev_deleted', Revision::DELETED_USER )} = 0";
+
+ $jconds = [
+ 'user' => [ 'LEFT JOIN', $actorQuery['fields']['rev_user'] . ' = user_id' ],
+ ] + $actorQuery['joins'];
+
+ $options = [
+ 'GROUP BY' => [ $fields['user_id'], $fields['user_name'] ],
+ 'ORDER BY' => 'timestamp DESC',
+ ];
+
+ $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds );
+ return new UserArrayFromResult( $res );
+ }
+
+ /**
+ * Should the parser cache be used?
+ *
+ * @param ParserOptions $parserOptions ParserOptions to check
+ * @param int $oldId
+ * @return bool
+ */
+ public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
+ return $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. Cache misses that result
+ * in parser runs are debounced with PoolCounter.
+ *
+ * @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)
+ * @param bool $forceParse Force reindexing, regardless of cache settings
+ * @return bool|ParserOutput ParserOutput or false if the revision was not found
+ */
+ public function getParserOutput(
+ ParserOptions $parserOptions, $oldid = null, $forceParse = false
+ ) {
+ $useParserCache =
+ ( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid );
+
+ if ( $useParserCache && !$parserOptions->isSafeToCache() ) {
+ throw new InvalidArgumentException(
+ 'The supplied ParserOptions are not safe to cache. Fix the options or set $forceParse = true.'
+ );
+ }
+
+ wfDebug( __METHOD__ .
+ ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
+ if ( $parserOptions->getStubThreshold() ) {
+ wfIncrStats( 'pcache.miss.stub' );
+ }
+
+ if ( $useParserCache ) {
+ $parserOutput = MediaWikiServices::getInstance()->getParserCache()
+ ->get( $this, $parserOptions );
+ if ( $parserOutput !== false ) {
+ return $parserOutput;
+ }
+ }
+
+ if ( $oldid === null || $oldid === 0 ) {
+ $oldid = $this->getLatest();
+ }
+
+ $pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache );
+ $pool->execute();
+
+ return $pool->getParserOutput();
+ }
+
+ /**
+ * Do standard deferred updates after page view (existing or missing page)
+ * @param User $user The relevant user
+ * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed
+ */
+ public function doViewUpdates( User $user, $oldid = 0 ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ // Update newtalk / watchlist notification status;
+ // Avoid outage if the master is not reachable by using a deferred updated
+ DeferredUpdates::addCallableUpdate(
+ function () use ( $user, $oldid ) {
+ Hooks::run( 'PageViewUpdates', [ $this, $user ] );
+
+ $user->clearNotification( $this->mTitle, $oldid );
+ },
+ DeferredUpdates::PRESEND
+ );
+ }
+
+ /**
+ * Perform the actions of a page purging
+ * @return bool
+ * @note In 1.28 (and only 1.28), this took a $flags parameter that
+ * controlled how much purging was done.
+ */
+ public function doPurge() {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ if ( !Hooks::run( 'ArticlePurge', [ &$wikiPage ] ) ) {
+ return false;
+ }
+
+ $this->mTitle->invalidateCache();
+
+ // Clear file cache
+ HTMLFileCache::clearFileCache( $this->getTitle() );
+ // Send purge after above page_touched update was committed
+ DeferredUpdates::addUpdate(
+ new CdnCacheUpdate( $this->mTitle->getCdnUrls() ),
+ DeferredUpdates::PRESEND
+ );
+
+ if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
+ $messageCache = MessageCache::singleton();
+ $messageCache->updateMessageOverride( $this->mTitle, $this->getContent() );
+ }
+
+ 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 IDatabase $dbw
+ * @param int|null $pageId Custom page ID that will be used for the insert statement
+ *
+ * @return bool|int The newly created page_id key; false if the row was not
+ * inserted, e.g. because the title already existed or because the specified
+ * page ID is already in use.
+ */
+ public function insertOn( $dbw, $pageId = null ) {
+ $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : [];
+ $dbw->insert(
+ 'page',
+ [
+ 'page_namespace' => $this->mTitle->getNamespace(),
+ 'page_title' => $this->mTitle->getDBkey(),
+ '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...
+ ] + $pageIdForInsert,
+ __METHOD__,
+ 'IGNORE'
+ );
+
+ if ( $dbw->affectedRows() > 0 ) {
+ $newid = $pageId ? (int)$pageId : $dbw->insertId();
+ $this->mId = $newid;
+ $this->mTitle->resetArticleID( $newid );
+
+ return $newid;
+ } else {
+ return false; // nothing changed
+ }
+ }
+
+ /**
+ * Update the page record to point to a newly saved revision.
+ *
+ * @param IDatabase $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 Success; false if the page row was missing or page_latest changed
+ */
+ public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
+ $lastRevIsRedirect = null
+ ) {
+ global $wgContentHandlerUseDB;
+
+ // Assertion to try to catch T92046
+ if ( (int)$revision->getId() === 0 ) {
+ throw new InvalidArgumentException(
+ __METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 )
+ );
+ }
+
+ $content = $revision->getContent();
+ $len = $content ? $content->getSize() : 0;
+ $rt = $content ? $content->getUltimateRedirectTarget() : null;
+
+ $conditions = [ 'page_id' => $this->getId() ];
+
+ if ( !is_null( $lastRevision ) ) {
+ // An extra check against threads stepping on each other
+ $conditions['page_latest'] = $lastRevision;
+ }
+
+ $revId = $revision->getId();
+ Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' );
+
+ $row = [ /* SET */
+ 'page_latest' => $revId,
+ 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ),
+ '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->mLatest = $revision->getId();
+ $this->mIsRedirect = (bool)$rt;
+ // Update the LinkCache.
+ LinkCache::singleton()->addGoodLinkObj(
+ $this->getId(),
+ $this->mTitle,
+ $len,
+ $this->mIsRedirect,
+ $this->mLatest,
+ $revision->getContentModel()
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Add row to the redirect table if this is a redirect, remove otherwise.
+ *
+ * @param IDatabase $dbw
+ * @param Title|null $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;
+ }
+
+ if ( $isRedirect ) {
+ $this->insertRedirectEntry( $redirectTitle );
+ } else {
+ // This is not a redirect, remove row from redirect table
+ $where = [ 'rd_from' => $this->getId() ];
+ $dbw->delete( 'redirect', $where, __METHOD__ );
+ }
+
+ if ( $this->getTitle()->getNamespace() == NS_FILE ) {
+ RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() );
+ }
+
+ 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 IDatabase $dbw
+ * @param Revision $revision
+ * @return bool
+ */
+ public function updateIfNewerOn( $dbw, $revision ) {
+ $row = $dbw->selectRow(
+ [ 'revision', 'page' ],
+ [ 'rev_id', 'rev_timestamp', 'page_is_redirect' ],
+ [
+ 'page_id' => $this->getId(),
+ 'page_latest=rev_id' ],
+ __METHOD__ );
+
+ if ( $row ) {
+ if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) {
+ 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 );
+
+ 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 Content|bool Content 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 );
+ }
+
+ /**
+ * 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|int|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|null 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
+ ) {
+ $baseRevId = null;
+ if ( $edittime && $sectionId !== 'new' ) {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $dbr = $lb->getConnection( DB_REPLICA );
+ $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
+ // Try the master if this thread may have just added it.
+ // This could be abstracted into a Revision method, but we don't want
+ // to encourage loading of revisions by timestamp.
+ if ( !$rev
+ && $lb->getServerCount() > 1
+ && $lb->hasOrMadeRecentMasterChanges()
+ ) {
+ $dbw = $lb->getConnection( DB_MASTER );
+ $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime );
+ }
+ if ( $rev ) {
+ $baseRevId = $rev->getId();
+ }
+ }
+
+ return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
+ }
+
+ /**
+ * @param string|int|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|null New complete article content, or null if error.
+ *
+ * @since 1.24
+ */
+ public function replaceSectionAtRev( $sectionId, Content $sectionContent,
+ $sectionTitle = '', $baseRevId = null
+ ) {
+ if ( strval( $sectionId ) === '' ) {
+ // Whole-page edit; let the whole text through
+ $newContent = $sectionContent;
+ } else {
+ if ( !$this->supportsSections() ) {
+ throw new MWException( "sections not supported for content model " .
+ $this->getContentHandler()->getModelID() );
+ }
+
+ // T32711: always use current version when adding a new section
+ if ( is_null( $baseRevId ) || $sectionId === 'new' ) {
+ $oldContent = $this->getContent();
+ } else {
+ $rev = Revision::newFromId( $baseRevId );
+ if ( !$rev ) {
+ wfDebug( __METHOD__ . " asked for bogus section (page: " .
+ $this->getId() . "; section: $sectionId)\n" );
+ return null;
+ }
+
+ $oldContent = $rev->getContent();
+ }
+
+ if ( !$oldContent ) {
+ wfDebug( __METHOD__ . ": no page text\n" );
+ return null;
+ }
+
+ $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
+ }
+
+ 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 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_AUTOSUMMARY
+ * Fill in blank summaries with generated text where possible
+ * EDIT_INTERNAL
+ * Signal that the page retrieve/save cycle happened entirely in this request.
+ *
+ * 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.
+ * This is not the parent revision ID, rather the revision ID for older
+ * content used as the source for a rollback, for example.
+ * @param User $user The user doing the edit
+ * @param string $serialFormat Format for storing the content in the
+ * database.
+ * @param array|null $tags Change tags to apply to this edit
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ * @param Int $undidRevId Id of revision that was undone or 0
+ *
+ * @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
+ * @throws MWException
+ */
+ public function doEditContent(
+ Content $content, $summary, $flags = 0, $baseRevId = false,
+ User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0
+ ) {
+ global $wgUser, $wgUseAutomaticEditSummaries;
+
+ // Old default parameter for $tags was null
+ if ( $tags === null ) {
+ $tags = [];
+ }
+
+ // Low-level sanity check
+ if ( $this->mTitle->getText() === '' ) {
+ throw new MWException( 'Something is trying to edit an article with an empty title' );
+ }
+ // Make sure the given content type is allowed for this page
+ if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
+ return Status::newFatal( 'content-not-allowed-here',
+ ContentHandler::getLocalizedName( $content->getModel() ),
+ $this->mTitle->getPrefixedText()
+ );
+ }
+
+ // 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' );
+
+ $user = $user ?: $wgUser;
+ $flags = $this->checkFlags( $flags );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ // Trigger pre-save hook (using provided edit summary)
+ $hookStatus = Status::newGood( [] );
+ $hook_args = [ &$wikiPage, &$user, &$content, &$summary,
+ $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
+ // Check if the hook rejected the attempted save
+ if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
+ if ( $hookStatus->isOK() ) {
+ // Hook returned false but didn't call fatal(); use generic message
+ $hookStatus->fatal( 'edit-hook-aborted' );
+ }
+
+ return $hookStatus;
+ }
+
+ $old_revision = $this->getRevision(); // current revision
+ $old_content = $this->getContent( Revision::RAW ); // current revision's content
+
+ $handler = $content->getContentHandler();
+ $tag = $handler->getChangeTag( $old_content, $content, $flags );
+ // If there is no applicable tag, null is returned, so we need to check
+ if ( $tag ) {
+ $tags[] = $tag;
+ }
+
+ // Check for undo tag
+ if ( $undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
+ $tags[] = 'mw-undo';
+ }
+
+ // Provide autosummaries if summary is not provided and autosummaries are enabled
+ if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
+ $summary = $handler->getAutosummary( $old_content, $content, $flags );
+ }
+
+ // Avoid statsd noise and wasted cycles check the edit stash (T136678)
+ if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
+ $useCache = false;
+ } else {
+ $useCache = true;
+ }
+
+ // Get the pre-save transform content and final parser output
+ $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
+ $pstContent = $editInfo->pstContent; // Content object
+ $meta = [
+ 'bot' => ( $flags & EDIT_FORCE_BOT ),
+ 'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
+ 'serialized' => $pstContent->serialize( $serialFormat ),
+ 'serialFormat' => $serialFormat,
+ 'baseRevId' => $baseRevId,
+ 'oldRevision' => $old_revision,
+ 'oldContent' => $old_content,
+ 'oldId' => $this->getLatest(),
+ 'oldIsRedirect' => $this->isRedirect(),
+ 'oldCountable' => $this->isCountable(),
+ 'tags' => ( $tags !== null ) ? (array)$tags : [],
+ 'undidRevId' => $undidRevId
+ ];
+
+ // Actually create the revision and create/update the page
+ if ( $flags & EDIT_UPDATE ) {
+ $status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
+ } else {
+ $status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
+ }
+
+ // Promote user to any groups they meet the criteria for
+ DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->addAutopromoteOnceGroups( 'onEdit' );
+ $user->addAutopromoteOnceGroups( 'onView' ); // b/c
+ } );
+
+ return $status;
+ }
+
+ /**
+ * @param Content $content Pre-save transform content
+ * @param int $flags
+ * @param User $user
+ * @param string $summary
+ * @param array $meta
+ * @return Status
+ * @throws DBUnexpectedError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function doModify(
+ Content $content, $flags, User $user, $summary, array $meta
+ ) {
+ global $wgUseRCPatrol;
+
+ // Update article, but only if changed.
+ $status = Status::newGood( [ 'new' => false, 'revision' => null ] );
+
+ // Convenience variables
+ $now = wfTimestampNow();
+ $oldid = $meta['oldId'];
+ /** @var Content|null $oldContent */
+ $oldContent = $meta['oldContent'];
+ $newsize = $content->getSize();
+
+ if ( !$oldid ) {
+ // Article gone missing
+ $status->fatal( 'edit-gone-missing' );
+
+ return $status;
+ } elseif ( !$oldContent ) {
+ // Sanity check for T39225
+ throw new MWException( "Could not find text for current revision {$oldid}." );
+ }
+
+ $changed = !$content->equals( $oldContent );
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( $changed ) {
+ // @TODO: pass content object?!
+ $revision = new Revision( [
+ 'page' => $this->getId(),
+ 'title' => $this->mTitle, // for determining the default content model
+ 'comment' => $summary,
+ 'minor_edit' => $meta['minor'],
+ 'text' => $meta['serialized'],
+ 'len' => $newsize,
+ 'parent_id' => $oldid,
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $now,
+ 'content_model' => $content->getModel(),
+ 'content_format' => $meta['serialFormat'],
+ ] );
+
+ $prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
+ $status->merge( $prepStatus );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $dbw->startAtomic( __METHOD__ );
+ // Get the latest page_latest value while locking it.
+ // Do a CAS style check to see if it's the same as when this method
+ // started. If it changed then bail out before touching the DB.
+ $latestNow = $this->lockAndGetLatest();
+ if ( $latestNow != $oldid ) {
+ $dbw->endAtomic( __METHOD__ );
+ // Page updated or deleted in the mean time
+ $status->fatal( 'edit-conflict' );
+
+ return $status;
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // Save the revision text
+ $revisionId = $revision->insertOn( $dbw );
+ // Update page_latest and friends to reflect the new revision
+ if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
+ throw new MWException( "Failed to update page row to use new revision." );
+ }
+
+ $tags = $meta['tags'];
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $this, $revision, $meta['baseRevId'], $user, &$tags ] );
+
+ // Update recentchanges
+ if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+ // Mark as patrolled if the user can do so
+ $autopatrolled = $wgUseRCPatrol && !count(
+ $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
+ // Add RC row to the DB
+ RecentChange::notifyEdit(
+ $now,
+ $this->mTitle,
+ $revision->isMinor(),
+ $user,
+ $summary,
+ $oldid,
+ $this->getTimestamp(),
+ $meta['bot'],
+ '',
+ $oldContent ? $oldContent->getSize() : 0,
+ $newsize,
+ $revisionId,
+ $autopatrolled ? RecentChange::PRC_AUTOPATROLLED :
+ RecentChange::PRC_UNPATROLLED,
+ $tags
+ );
+ }
+
+ $user->incEditCount();
+
+ $dbw->endAtomic( __METHOD__ );
+ $this->mTimestamp = $now;
+ } else {
+ // T34948: revision ID must be set to page {{REVISIONID}} and
+ // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
+ // Since we don't insert a new revision into the database, the least
+ // error-prone way is to reuse given old revision.
+ $revision = $meta['oldRevision'];
+ }
+
+ if ( $changed ) {
+ // Return the new revision to the caller
+ $status->value['revision'] = $revision;
+ } else {
+ $status->warning( 'edit-no-change' );
+ // Update page_touched as updateRevisionOn() was not called.
+ // Other cache updates are managed in onArticleEdit() via doEditUpdates().
+ $this->mTitle->invalidateCache( $now );
+ }
+
+ // Do secondary updates once the main changes have been committed...
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $revision, &$user, $content, $summary, &$flags,
+ $changed, $meta, &$status
+ ) {
+ // Update links tables, site stats, etc.
+ $this->doEditUpdates(
+ $revision,
+ $user,
+ [
+ 'changed' => $changed,
+ 'oldcountable' => $meta['oldCountable'],
+ 'oldrevision' => $meta['oldRevision']
+ ]
+ );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+ // Trigger post-save hook
+ $params = [ &$wikiPage, &$user, $content, $summary, $flags & EDIT_MINOR,
+ null, null, &$flags, $revision, &$status, $meta['baseRevId'],
+ $meta['undidRevId'] ];
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+ /**
+ * @param Content $content Pre-save transform content
+ * @param int $flags
+ * @param User $user
+ * @param string $summary
+ * @param array $meta
+ * @return Status
+ * @throws DBUnexpectedError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ */
+ private function doCreate(
+ Content $content, $flags, User $user, $summary, array $meta
+ ) {
+ global $wgUseRCPatrol, $wgUseNPPatrol;
+
+ $status = Status::newGood( [ 'new' => true, 'revision' => null ] );
+
+ $now = wfTimestampNow();
+ $newsize = $content->getSize();
+ $prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
+ $status->merge( $prepStatus );
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ // Add the page record unless one already exists for the title
+ $newid = $this->insertOn( $dbw );
+ if ( $newid === false ) {
+ $dbw->endAtomic( __METHOD__ ); // nothing inserted
+ $status->fatal( 'edit-already-exists' );
+
+ return $status; // nothing done
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // @TODO: pass content object?!
+ $revision = new Revision( [
+ 'page' => $newid,
+ 'title' => $this->mTitle, // for determining the default content model
+ 'comment' => $summary,
+ 'minor_edit' => $meta['minor'],
+ 'text' => $meta['serialized'],
+ 'len' => $newsize,
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => $now,
+ 'content_model' => $content->getModel(),
+ 'content_format' => $meta['serialFormat'],
+ ] );
+
+ // Save the revision text...
+ $revisionId = $revision->insertOn( $dbw );
+ // Update the page record with revision data
+ if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
+ throw new MWException( "Failed to update page row to use new revision." );
+ }
+
+ Hooks::run( 'NewRevisionFromEditComplete', [ $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
+ RecentChange::notifyNew(
+ $now,
+ $this->mTitle,
+ $revision->isMinor(),
+ $user,
+ $summary,
+ $meta['bot'],
+ '',
+ $newsize,
+ $revisionId,
+ $patrolled,
+ $meta['tags']
+ );
+ }
+
+ $user->incEditCount();
+
+ $dbw->endAtomic( __METHOD__ );
+ $this->mTimestamp = $now;
+
+ // Return the new revision to the caller
+ $status->value['revision'] = $revision;
+
+ // Do secondary updates once the main changes have been committed...
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $revision, &$user, $content, $summary, &$flags, $meta, &$status
+ ) {
+ // Update links, etc.
+ $this->doEditUpdates( $revision, $user, [ 'created' => true ] );
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+ // Trigger post-create hook
+ $params = [ &$wikiPage, &$user, $content, $summary,
+ $flags & EDIT_MINOR, null, null, &$flags, $revision ];
+ Hooks::run( 'PageContentInsertComplete', $params );
+ // Trigger post-save hook
+ $params = array_merge( $params, [ &$status, $meta['baseRevId'], 0 ] );
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ 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 content which is about to be saved.
+ *
+ * Prior to 1.30, this returned a stdClass object with the same class
+ * members.
+ *
+ * @param Content $content
+ * @param Revision|int|null $revision Revision object. For backwards compatibility, a
+ * revision ID is also accepted, but this is deprecated.
+ * @param User|null $user
+ * @param string|null $serialFormat
+ * @param bool $useCache Check shared prepared edit cache
+ *
+ * @return PreparedEdit
+ *
+ * @since 1.21
+ */
+ public function prepareContentForEdit(
+ Content $content, $revision = null, User $user = null,
+ $serialFormat = null, $useCache = true
+ ) {
+ global $wgContLang, $wgUser, $wgAjaxEditStash;
+
+ if ( is_object( $revision ) ) {
+ $revid = $revision->getId();
+ } else {
+ $revid = $revision;
+ // This code path is deprecated, and nothing is known to
+ // use it, so performance here shouldn't be a worry.
+ if ( $revid !== null ) {
+ wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' );
+ $revision = Revision::newFromId( $revid, Revision::READ_LATEST );
+ } else {
+ $revision = null;
+ }
+ }
+
+ $user = is_null( $user ) ? $wgUser : $user;
+ // XXX: check $user->getId() here???
+
+ // Use a sane default for $serialFormat, see T59026
+ if ( $serialFormat === null ) {
+ $serialFormat = $content->getContentHandler()->getDefaultFormat();
+ }
+
+ if ( $this->mPreparedEdit
+ && isset( $this->mPreparedEdit->newContent )
+ && $this->mPreparedEdit->newContent->equals( $content )
+ && $this->mPreparedEdit->revid == $revid
+ && $this->mPreparedEdit->format == $serialFormat
+ // XXX: also check $user here?
+ ) {
+ // Already prepared
+ return $this->mPreparedEdit;
+ }
+
+ // The edit may have already been prepared via api.php?action=stashedit
+ $cachedEdit = $useCache && $wgAjaxEditStash
+ ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
+ : false;
+
+ $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
+ Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
+
+ $edit = new PreparedEdit();
+ if ( $cachedEdit ) {
+ $edit->timestamp = $cachedEdit->timestamp;
+ } else {
+ $edit->timestamp = wfTimestampNow();
+ }
+ // @note: $cachedEdit is safely not used if the rev ID was referenced in the text
+ $edit->revid = $revid;
+
+ if ( $cachedEdit ) {
+ $edit->pstContent = $cachedEdit->pstContent;
+ } else {
+ $edit->pstContent = $content
+ ? $content->preSaveTransform( $this->mTitle, $user, $popts )
+ : null;
+ }
+
+ $edit->format = $serialFormat;
+ $edit->popts = $this->makeParserOptions( 'canonical' );
+ if ( $cachedEdit ) {
+ $edit->output = $cachedEdit->output;
+ } else {
+ if ( $revision ) {
+ // We get here if vary-revision is set. This means that this page references
+ // itself (such as via self-transclusion). In this case, we need to make sure
+ // that any such self-references refer to the newly-saved revision, and not
+ // to the previous one, which could otherwise happen due to replica DB lag.
+ $oldCallback = $edit->popts->getCurrentRevisionCallback();
+ $edit->popts->setCurrentRevisionCallback(
+ function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
+ if ( $title->equals( $revision->getTitle() ) ) {
+ return $revision;
+ } else {
+ return call_user_func( $oldCallback, $title, $parser );
+ }
+ }
+ );
+ } else {
+ // Try to avoid a second parse if {{REVISIONID}} is used
+ $dbIndex = ( $this->mDataLoadedFrom & self::READ_LATEST ) === self::READ_LATEST
+ ? DB_MASTER // use the best possible guess
+ : DB_REPLICA; // T154554
+
+ $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
+ return 1 + (int)wfGetDB( $dbIndex )->selectField(
+ 'revision',
+ 'MAX(rev_id)',
+ [],
+ __METHOD__
+ );
+ } );
+ }
+ $edit->output = $edit->pstContent
+ ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
+ : null;
+ }
+
+ $edit->newContent = $content;
+ $edit->oldContent = $this->getContent( Revision::RAW );
+
+ if ( $edit->output ) {
+ $edit->output->setCacheTime( wfTimestampNow() );
+ }
+
+ // Process cache the result
+ $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: bool, whether the revision changed the content (default true)
+ * - created: bool, whether the revision created the page (default false)
+ * - moved: bool, whether the page was moved (default false)
+ * - restored: bool, whether the page was undeleted (default false)
+ * - oldrevision: Revision object for the pre-update revision (default null)
+ * - oldcountable: bool, null, or string 'no-change' (default null):
+ * - bool: whether the page was counted as an article before that
+ * revision, only used in changed is true and created is false
+ * - null: if created is false, don't update the article count; if created
+ * is true, do update the article count
+ * - 'no-change': don't update the article count, ever
+ */
+ public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
+ global $wgRCWatchCategoryMembership;
+
+ $options += [
+ 'changed' => true,
+ 'created' => false,
+ 'moved' => false,
+ 'restored' => false,
+ 'oldrevision' => null,
+ 'oldcountable' => null
+ ];
+ $content = $revision->getContent();
+
+ $logger = LoggerFactory::getInstance( 'SaveParse' );
+
+ // See if the parser output before $revision was inserted is still valid
+ $editInfo = false;
+ if ( !$this->mPreparedEdit ) {
+ $logger->debug( __METHOD__ . ": No prepared edit...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
+ && $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
+ ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
+ } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
+ $logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
+ } else {
+ wfDebug( __METHOD__ . ": Using prepared edit...\n" );
+ $editInfo = $this->mPreparedEdit;
+ }
+
+ if ( !$editInfo ) {
+ // Parse the text again if needed. Be careful not to do pre-save transform twice:
+ // $text is usually already pre-save transformed once. Avoid using the edit stash
+ // as any prepared content from there or in doEditContent() was already rejected.
+ $editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false );
+ }
+
+ // Save it to the parser cache.
+ // Make sure the cache time matches page_touched to avoid double parsing.
+ MediaWikiServices::getInstance()->getParserCache()->save(
+ $editInfo->output, $this, $editInfo->popts,
+ $revision->getTimestamp(), $editInfo->revid
+ );
+
+ // Update the links tables and other secondary data
+ if ( $content ) {
+ $recursive = $options['changed']; // T52785
+ $updates = $content->getSecondaryDataUpdates(
+ $this->getTitle(), null, $recursive, $editInfo->output
+ );
+ foreach ( $updates as $update ) {
+ $update->setCause( 'edit-page', $user->getName() );
+ if ( $update instanceof LinksUpdate ) {
+ $update->setRevision( $revision );
+ $update->setTriggeringUser( $user );
+ }
+ DeferredUpdates::addUpdate( $update );
+ }
+ if ( $wgRCWatchCategoryMembership
+ && $this->getContentHandler()->supportsCategories() === true
+ && ( $options['changed'] || $options['created'] )
+ && !$options['restored']
+ ) {
+ // Note: jobs are pushed after deferred updates, so the job should be able to see
+ // the recent change entry (also done via deferred updates) and carry over any
+ // bot/deletion/IP flags, ect.
+ JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
+ $this->getTitle(),
+ [
+ 'pageId' => $this->getId(),
+ 'revTimestamp' => $revision->getTimestamp()
+ ]
+ ) );
+ }
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $options['changed'] ] );
+
+ if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
+ // Flush old entries from the `recentchanges` table
+ if ( mt_rand( 0, 9 ) == 0 ) {
+ JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
+ }
+ }
+
+ if ( !$this->exists() ) {
+ return;
+ }
+
+ $id = $this->getId();
+ $title = $this->mTitle->getPrefixedDBkey();
+ $shortTitle = $this->mTitle->getDBkey();
+
+ if ( $options['oldcountable'] === 'no-change' ||
+ ( !$options['changed'] && !$options['moved'] )
+ ) {
+ $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;
+ $pages = $options['created'] ? 1 : 0;
+
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
+ [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
+ ) );
+ 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 {
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ // Allow extensions to prevent user notification
+ // when a new message is added to their talk page
+ if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $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 ) {
+ MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content );
+ }
+
+ if ( $options['created'] ) {
+ self::onArticleCreate( $this->mTitle );
+ } elseif ( $options['changed'] ) { // T52785
+ self::onArticleEdit( $this->mTitle, $revision );
+ }
+
+ ResourceLoaderWikiModule::invalidateModuleCache(
+ $this->mTitle, $options['oldrevision'], $revision, wfWikiID()
+ );
+ }
+
+ /**
+ * 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
+ * @param string|string[] $tags Change tags to add to the pages and protection log entries
+ * ($user should be able to add the specified tags before this is called)
+ * @return Status Status object; if action is taken, $status->value is the log_id of the
+ * protection log entry.
+ */
+ public function doUpdateRestrictions( array $limit, array $expiry,
+ &$cascade, $reason, User $user, $tags = null
+ ) {
+ global $wgCascadingRestrictionLevels;
+
+ if ( wfReadOnly() ) {
+ return Status::newFatal( wfMessage( '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() ) {
+ $expiry[$action] = 'infinity';
+ }
+ 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-comment';
+ $logAction = 'unprotect';
+ } elseif ( $isProtected ) {
+ $revCommentMsg = 'modifiedarticleprotection-comment';
+ $logAction = 'modify';
+ } else {
+ $revCommentMsg = 'protectedarticle-comment';
+ $logAction = 'protect';
+ }
+
+ $logRelationsValues = [];
+ $logRelationsField = null;
+ $logParamsDetails = [];
+
+ // Null revision (used for change tag insertion)
+ $nullRevision = null;
+
+ if ( $id ) { // Protection of existing page
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ if ( !Hooks::run( 'ArticleProtect', [ &$wikiPage, &$user, $limit, $reason ] ) ) {
+ return Status::newGood();
+ }
+
+ // Only certain restrictions can cascade...
+ $editrestriction = isset( $limit['edit'] )
+ ? [ $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',
+ [
+ 'pr_page' => $id,
+ 'pr_type' => $action
+ ],
+ __METHOD__
+ );
+ if ( $restrictions != '' ) {
+ $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
+ $dbw->insert(
+ 'page_restrictions',
+ [
+ 'pr_page' => $id,
+ 'pr_type' => $action,
+ 'pr_level' => $restrictions,
+ 'pr_cascade' => $cascadeValue,
+ 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
+ ],
+ __METHOD__
+ );
+ $logRelationsValues[] = $dbw->insertId();
+ $logParamsDetails[] = [
+ 'type' => $action,
+ 'level' => $restrictions,
+ 'expiry' => $expiry[$action],
+ 'cascade' => (bool)$cascadeValue,
+ ];
+ }
+ }
+
+ // Clear out legacy restriction fields
+ $dbw->update(
+ 'page',
+ [ 'page_restrictions' => '' ],
+ [ 'page_id' => $id ],
+ __METHOD__
+ );
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ Hooks::run( 'NewRevisionFromEditComplete',
+ [ $this, $nullRevision, $latest, $user ] );
+ Hooks::run( 'ArticleProtectComplete', [ &$wikiPage, &$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'] != '' ) {
+ $commentFields = CommentStore::getStore()->insert( $dbw, 'pt_reason', $reason );
+ $dbw->replace( 'protected_titles',
+ [ [ 'pt_namespace', 'pt_title' ] ],
+ [
+ '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(),
+ ] + $commentFields, __METHOD__
+ );
+ $logParamsDetails[] = [
+ 'type' => 'create',
+ 'level' => $limit['create'],
+ 'expiry' => $expiry['create'],
+ ];
+ } else {
+ $dbw->delete( 'protected_titles',
+ [
+ 'pt_namespace' => $this->mTitle->getNamespace(),
+ 'pt_title' => $this->mTitle->getDBkey()
+ ], __METHOD__
+ );
+ }
+ }
+
+ $this->mTitle->flushRestrictions();
+ InfoAction::invalidateCache( $this->mTitle );
+
+ if ( $logAction == 'unprotect' ) {
+ $params = [];
+ } else {
+ $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
+ $params = [
+ '4::description' => $protectDescriptionLog, // parameter for IRC
+ '5:bool:cascade' => $cascade,
+ 'details' => $logParamsDetails, // parameter for localize and api
+ ];
+ }
+
+ // Update the protection log
+ $logEntry = new ManualLogEntry( 'protect', $logAction );
+ $logEntry->setTarget( $this->mTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setPerformer( $user );
+ $logEntry->setParameters( $params );
+ if ( !is_null( $nullRevision ) ) {
+ $logEntry->setAssociatedRevId( $nullRevision->getId() );
+ }
+ $logEntry->setTags( $tags );
+ if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
+ $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
+ }
+ $logId = $logEntry->insert();
+ $logEntry->publish( $logId );
+
+ return Status::newGood( $logId );
+ }
+
+ /**
+ * 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
+ ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ // Prepare a null revision to be added to the history
+ $editComment = wfMessage(
+ $revCommentMsg,
+ $this->mTitle->getPrefixedText(),
+ $user ? $user->getName() : ''
+ )->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;
+
+ if ( $expiry != '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 = [ '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 = [ '', '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( __METHOD__ . ' given non-array restriction set' );
+ }
+
+ $bits = [];
+ 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 $u1 Unused
+ * @param bool $u2 Unused
+ * @param array|string &$error Array of errors to append to
+ * @param User $user The deleting user
+ * @return bool True if successful
+ */
+ public function doDeleteArticle(
+ $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null
+ ) {
+ $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $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 $u1 Unused
+ * @param bool $u2 Unused
+ * @param array|string &$error Array of errors to append to
+ * @param User $deleter The deleting user
+ * @param array $tags Tags to apply to the deletion action
+ * @param string $logsubtype
+ * @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, $u1 = null, $u2 = null, &$error = '', User $deleter = null,
+ $tags = [], $logsubtype = 'delete'
+ ) {
+ global $wgUser, $wgContentHandlerUseDB, $wgCommentTableSchemaMigrationStage,
+ $wgActorTableSchemaMigrationStage;
+
+ wfDebug( __METHOD__ . "\n" );
+
+ $status = Status::newGood();
+
+ if ( $this->mTitle->getDBkey() === '' ) {
+ $status->error( 'cannotdelete',
+ wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
+ return $status;
+ }
+
+ // Avoid PHP 7.1 warning of passing $this by reference
+ $wikiPage = $this;
+
+ $deleter = is_null( $deleter ) ? $wgUser : $deleter;
+ if ( !Hooks::run( 'ArticleDelete',
+ [ &$wikiPage, &$deleter, &$reason, &$error, &$status, $suppress ]
+ ) ) {
+ if ( $status->isOK() ) {
+ // Hook aborted but didn't set a fatal status
+ $status->fatal( 'delete-hook-aborted' );
+ }
+ return $status;
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $this->loadPageData( self::READ_LATEST );
+ $id = $this->getId();
+ // T98706: lock the page from various other updates but avoid using
+ // WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
+ // the revisions queries (which also JOIN on user). Only lock the page
+ // row and CAS check on page_latest to see if the trx snapshot matches.
+ $lockedLatest = $this->lockAndGetLatest();
+ if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
+ $dbw->endAtomic( __METHOD__ );
+ // Page not there or trx snapshot is stale
+ $status->error( 'cannotdelete',
+ wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
+ return $status;
+ }
+
+ // Given the lock above, we can be confident in the title and page ID values
+ $namespace = $this->getTitle()->getNamespace();
+ $dbKey = $this->getTitle()->getDBkey();
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // we need to remember the old content so we can use it to generate all deletion updates.
+ $revision = $this->getRevision();
+ try {
+ $content = $this->getContent( Revision::RAW );
+ } catch ( Exception $ex ) {
+ wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
+ . $ex->getMessage() );
+
+ $content = null;
+ }
+
+ $commentStore = CommentStore::getStore();
+ $actorMigration = ActorMigration::newMigration();
+
+ $revQuery = Revision::getQueryInfo();
+ $bitfield = false;
+
+ // Bitfields to further suppress the content
+ if ( $suppress ) {
+ $bitfield = Revision::SUPPRESSED_ALL;
+ $revQuery['fields'] = array_diff( $revQuery['fields'], [ '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.
+ // In the future, we may keep revisions and mark them with
+ // the rev_deleted field, which is reserved for this purpose.
+
+ // Lock rows in `revision` and its temp tables, but not any others.
+ // Note array_intersect() preserves keys from the first arg, and we're
+ // assuming $revQuery has `revision` primary and isn't using subtables
+ // for anything we care about.
+ $res = $dbw->select(
+ array_intersect(
+ $revQuery['tables'],
+ [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ]
+ ),
+ '1',
+ [ 'rev_page' => $id ],
+ __METHOD__,
+ 'FOR UPDATE',
+ $revQuery['joins']
+ );
+ foreach ( $res as $row ) {
+ // Fetch all rows in case the DB needs that to properly lock them.
+ }
+
+ // Get all of the page revisions
+ $res = $dbw->select(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [ 'rev_page' => $id ],
+ __METHOD__,
+ [],
+ $revQuery['joins']
+ );
+
+ // Build their equivalent archive rows
+ $rowsInsert = [];
+ $revids = [];
+
+ /** @var int[] Revision IDs of edits that were made by IPs */
+ $ipRevIds = [];
+
+ foreach ( $res as $row ) {
+ $comment = $commentStore->getComment( 'rev_comment', $row );
+ $user = User::newFromAnyId( $row->rev_user, $row->rev_user_text, $row->rev_actor );
+ $rowInsert = [
+ 'ar_namespace' => $namespace,
+ 'ar_title' => $dbKey,
+ 'ar_timestamp' => $row->rev_timestamp,
+ 'ar_minor_edit' => $row->rev_minor_edit,
+ 'ar_rev_id' => $row->rev_id,
+ 'ar_parent_id' => $row->rev_parent_id,
+ 'ar_text_id' => $row->rev_text_id,
+ 'ar_len' => $row->rev_len,
+ 'ar_page_id' => $id,
+ 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
+ 'ar_sha1' => $row->rev_sha1,
+ ] + $commentStore->insert( $dbw, 'ar_comment', $comment )
+ + $actorMigration->getInsertValues( $dbw, 'ar_user', $user );
+ if ( $wgContentHandlerUseDB ) {
+ $rowInsert['ar_content_model'] = $row->rev_content_model;
+ $rowInsert['ar_content_format'] = $row->rev_content_format;
+ }
+ $rowsInsert[] = $rowInsert;
+ $revids[] = $row->rev_id;
+
+ // Keep track of IP edits, so that the corresponding rows can
+ // be deleted in the ip_changes table.
+ if ( (int)$row->rev_user === 0 && IP::isValid( $row->rev_user_text ) ) {
+ $ipRevIds[] = $row->rev_id;
+ }
+ }
+ // Copy them into the archive table
+ $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
+ // Save this so we can pass it to the ArticleDeleteComplete hook.
+ $archivedRevisionCount = $dbw->affectedRows();
+
+ // Clone the title and wikiPage, so we have the information we need when
+ // we log and run the ArticleDeleteComplete hook.
+ $logTitle = clone $this->mTitle;
+ $wikiPageBeforeDelete = clone $this;
+
+ // Now that it's safely backed up, delete it
+ $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
+ $dbw->delete( 'revision', [ 'rev_page' => $id ], __METHOD__ );
+ if ( $wgCommentTableSchemaMigrationStage > MIGRATION_OLD ) {
+ $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ );
+ }
+ if ( $wgActorTableSchemaMigrationStage > MIGRATION_OLD ) {
+ $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ );
+ }
+
+ // Also delete records from ip_changes as applicable.
+ if ( count( $ipRevIds ) > 0 ) {
+ $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ );
+ }
+
+ // Log the deletion, if the page was suppressed, put it in the suppression log instead
+ $logtype = $suppress ? 'suppress' : 'delete';
+
+ $logEntry = new ManualLogEntry( $logtype, $logsubtype );
+ $logEntry->setPerformer( $deleter );
+ $logEntry->setTarget( $logTitle );
+ $logEntry->setComment( $reason );
+ $logEntry->setTags( $tags );
+ $logid = $logEntry->insert();
+
+ $dbw->onTransactionPreCommitOrIdle(
+ function () use ( $dbw, $logEntry, $logid ) {
+ // T58776: avoid deadlocks (especially from FileDeleteForm)
+ $logEntry->publish( $logid );
+ },
+ __METHOD__
+ );
+
+ $dbw->endAtomic( __METHOD__ );
+
+ $this->doDeleteUpdates( $id, $content, $revision, $deleter );
+
+ Hooks::run( 'ArticleDeleteComplete', [
+ &$wikiPageBeforeDelete,
+ &$deleter,
+ $reason,
+ $id,
+ $content,
+ $logEntry,
+ $archivedRevisionCount
+ ] );
+ $status->value = $logid;
+
+ // Show log excerpt on 404 pages rather than just a link
+ $cache = MediaWikiServices::getInstance()->getMainObjectStash();
+ $key = $cache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
+ $cache->set( $key, 1, $cache::TTL_DAY );
+
+ return $status;
+ }
+
+ /**
+ * Lock the page row for this title+id and return page_latest (or 0)
+ *
+ * @return int Returns 0 if no row was found with this title+id
+ * @since 1.27
+ */
+ public function lockAndGetLatest() {
+ return (int)wfGetDB( DB_MASTER )->selectField(
+ 'page',
+ 'page_latest',
+ [
+ 'page_id' => $this->getId(),
+ // Typically page_id is enough, but some code might try to do
+ // updates assuming the title is the same, so verify that
+ 'page_namespace' => $this->getTitle()->getNamespace(),
+ 'page_title' => $this->getTitle()->getDBkey()
+ ],
+ __METHOD__,
+ [ 'FOR UPDATE' ]
+ );
+ }
+
+ /**
+ * Do some database updates after deletion
+ *
+ * @param int $id The page_id value of the page being deleted
+ * @param Content|null $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.
+ * @param Revision|null $revision The latest page revision
+ * @param User|null $user The user that caused the deletion
+ */
+ public function doDeleteUpdates(
+ $id, Content $content = null, Revision $revision = null, User $user = null
+ ) {
+ try {
+ $countable = $this->isCountable();
+ } catch ( Exception $ex ) {
+ // fallback for deleting broken pages for which we cannot load the content for
+ // some reason. Note that doDeleteArticleReal() already logged this problem.
+ $countable = false;
+ }
+
+ // Update site status
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
+ [ 'edits' => 1, 'articles' => -$countable, 'pages' => -1 ]
+ ) );
+
+ // Delete pagelinks, update secondary indexes, etc
+ $updates = $this->getDeletionUpdates( $content );
+ foreach ( $updates as $update ) {
+ DeferredUpdates::addUpdate( $update );
+ }
+
+ $causeAgent = $user ? $user->getName() : 'unknown';
+ // Reparse any pages transcluding this page
+ LinksUpdate::queueRecursiveJobsForTable(
+ $this->mTitle, 'templatelinks', 'delete-page', $causeAgent );
+ // Reparse any pages including this image
+ if ( $this->mTitle->getNamespace() == NS_FILE ) {
+ LinksUpdate::queueRecursiveJobsForTable(
+ $this->mTitle, 'imagelinks', 'delete-page', $causeAgent );
+ }
+
+ // Clear caches
+ self::onArticleDelete( $this->mTitle );
+ ResourceLoaderWikiModule::invalidateModuleCache(
+ $this->mTitle, $revision, null, wfWikiID()
+ );
+
+ // 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
+ * @todo Remove $token parameter. Already verified by RollbackAction and ApiRollback.
+ *
+ * @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
+ * @param array|null $tags Change tags to apply to the rollback
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ *
+ * @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, $tags = null
+ ) {
+ $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, 'rollback' ) ) {
+ $errors[] = [ 'sessionfailure' ];
+ }
+
+ if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
+ $errors[] = [ 'actionthrottledtext' ];
+ }
+
+ // If there were errors, bail out now
+ if ( !empty( $errors ) ) {
+ return $errors;
+ }
+
+ return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
+ }
+
+ /**
+ * 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
+ * @param array|null $tags Change tags to apply to the rollback
+ * Callers are responsible for permission checks
+ * (with ChangeTags::canAddTagsAccompanyingChange)
+ *
+ * @return array
+ */
+ public function commitRollback( $fromP, $summary, $bot,
+ &$resultDetails, User $guser, $tags = null
+ ) {
+ global $wgUseRCPatrol, $wgContLang;
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( wfReadOnly() ) {
+ return [ [ 'readonlytext' ] ];
+ }
+
+ // Get the last editor
+ $current = $this->getRevision();
+ if ( is_null( $current ) ) {
+ // Something wrong... no page?
+ return [ [ '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 = [ 'current' => $current ];
+ return [ [ 'alreadyrolled',
+ htmlspecialchars( $this->mTitle->getPrefixedText() ),
+ htmlspecialchars( $fromP ),
+ htmlspecialchars( $current->getUserText() )
+ ] ];
+ }
+
+ // Get the last edit not by this person...
+ // Note: these may not be public values
+ $userId = intval( $current->getUser( Revision::RAW ) );
+ $userName = $current->getUserText( Revision::RAW );
+ if ( $userId ) {
+ $user = User::newFromId( $userId );
+ $user->setName( $userName );
+ } else {
+ $user = User::newFromName( $current->getUserText( Revision::RAW ), false );
+ }
+
+ $actorWhere = ActorMigration::newMigration()->getWhere( $dbw, 'rev_user', $user );
+
+ $s = $dbw->selectRow(
+ [ 'revision' ] + $actorWhere['tables'],
+ [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
+ [
+ 'rev_page' => $current->getPage(),
+ 'NOT(' . $actorWhere['conds'] . ')',
+ ],
+ __METHOD__,
+ [
+ 'USE INDEX' => [ 'revision' => 'page_timestamp' ],
+ 'ORDER BY' => 'rev_timestamp DESC'
+ ],
+ $actorWhere['joins']
+ );
+ if ( $s === false ) {
+ // No one else ever edited this page
+ return [ [ 'cantrollback' ] ];
+ } elseif ( $s->rev_deleted & Revision::DELETED_TEXT
+ || $s->rev_deleted & Revision::DELETED_USER
+ ) {
+ // Only admins can see this text
+ return [ [ 'notvisiblerev' ] ];
+ }
+
+ // Generate the edit summary if necessary
+ $target = Revision::newFromId( $s->rev_id, Revision::READ_LATEST );
+ 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 = [
+ $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 );
+
+ // Save
+ $flags = EDIT_UPDATE | EDIT_INTERNAL;
+
+ if ( $guser->isAllowed( 'minoredit' ) ) {
+ $flags |= EDIT_MINOR;
+ }
+
+ if ( $bot && ( $guser->isAllowedAny( 'markbotedits', 'bot' ) ) ) {
+ $flags |= EDIT_FORCE_BOT;
+ }
+
+ $targetContent = $target->getContent();
+ $changingContentModel = $targetContent->getModel() !== $current->getContentModel();
+
+ if ( in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
+ $tags[] = 'mw-rollback';
+ }
+
+ // Actually store the edit
+ $status = $this->doEditContent(
+ $targetContent,
+ $summary,
+ $flags,
+ $target->getId(),
+ $guser,
+ null,
+ $tags
+ );
+
+ // Set patrolling and bot flag on the edits, which gets rollbacked.
+ // This is done even on edit failure to have patrolling in that case (T64157).
+ $set = [];
+ 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'] = RecentChange::PRC_PATROLLED;
+ }
+
+ if ( count( $set ) ) {
+ $actorWhere = ActorMigration::newMigration()->getWhere( $dbw, 'rc_user', $user, false );
+ $dbw->update( 'recentchanges', $set,
+ [ /* WHERE */
+ 'rc_cur_id' => $current->getPage(),
+ 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
+ $actorWhere['conds'], // No tables/joins are needed for rc_user
+ ],
+ __METHOD__
+ );
+ }
+
+ if ( !$status->isOK() ) {
+ return $status->getErrorsArray();
+ }
+
+ // raise error, when the edit is an edit without a new version
+ $statusRev = isset( $status->value['revision'] )
+ ? $status->value['revision']
+ : null;
+ if ( !( $statusRev instanceof Revision ) ) {
+ $resultDetails = [ 'current' => $current ];
+ return [ [ 'alreadyrolled',
+ htmlspecialchars( $this->mTitle->getPrefixedText() ),
+ htmlspecialchars( $fromP ),
+ htmlspecialchars( $current->getUserText() )
+ ] ];
+ }
+
+ if ( $changingContentModel ) {
+ // If the content model changed during the rollback,
+ // make sure it gets logged to Special:Log/contentmodel
+ $log = new ManualLogEntry( 'contentmodel', 'change' );
+ $log->setPerformer( $guser );
+ $log->setTarget( $this->mTitle );
+ $log->setComment( $summary );
+ $log->setParameters( [
+ '4::oldmodel' => $current->getContentModel(),
+ '5::newmodel' => $targetContent->getModel(),
+ ] );
+
+ $logId = $log->insert( $dbw );
+ $log->publish( $logId );
+ }
+
+ $revId = $statusRev->getId();
+
+ Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $target, $current ] );
+
+ $resultDetails = [
+ 'summary' => $summary,
+ 'current' => $current,
+ 'target' => $target,
+ 'newid' => $revId,
+ 'tags' => $tags
+ ];
+
+ return [];
+ }
+
+ /**
+ * 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 $title ) {
+ // Update existence markers on article/talk tabs...
+ $other = $title->getOtherPage();
+
+ $other->purgeSquid();
+
+ $title->touchLinks();
+ $title->purgeSquid();
+ $title->deleteTitleProtection();
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // Invalidate caches of articles which include this page
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $title, 'templatelinks', 'page-create' )
+ );
+
+ if ( $title->getNamespace() == NS_CATEGORY ) {
+ // Load the Category object, which will schedule a job to create
+ // the category table row if necessary. Checking a replica DB is ok
+ // here, in the worst case it'll run an unnecessary recount job on
+ // a category that probably doesn't have many members.
+ Category::newFromTitle( $title )->getID();
+ }
+ }
+
+ /**
+ * Clears caches when article is deleted
+ *
+ * @param Title $title
+ */
+ public static function onArticleDelete( Title $title ) {
+ // Update existence markers on article/talk tabs...
+ // Clear Backlink cache first so that purge jobs use more up-to-date backlink information
+ BacklinkCache::get( $title )->clear();
+ $other = $title->getOtherPage();
+
+ $other->purgeSquid();
+
+ $title->touchLinks();
+ $title->purgeSquid();
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // File cache
+ HTMLFileCache::clearFileCache( $title );
+ InfoAction::invalidateCache( $title );
+
+ // Messages
+ if ( $title->getNamespace() == NS_MEDIAWIKI ) {
+ MessageCache::singleton()->updateMessageOverride( $title, null );
+ }
+
+ // Images
+ if ( $title->getNamespace() == NS_FILE ) {
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $title, 'imagelinks', 'page-delete' )
+ );
+ }
+
+ // 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
+ * @param Revision|null $revision Revision that was just saved, may be null
+ */
+ public static function onArticleEdit( Title $title, Revision $revision = null ) {
+ // Invalidate caches of articles which include this page
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' )
+ );
+
+ // Invalidate the caches of all pages which redirect here
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $title, 'redirect', 'page-edit' )
+ );
+
+ MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
+
+ // Purge CDN for this page only
+ $title->purgeSquid();
+ // Clear file cache for this page only
+ HTMLFileCache::clearFileCache( $title );
+
+ $revid = $revision ? $revision->getId() : null;
+ DeferredUpdates::addCallableUpdate( function () use ( $title, $revid ) {
+ InfoAction::invalidateCache( $title, $revid );
+ } );
+ }
+
+ /**#@-*/
+
+ /**
+ * 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( [] ) );
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'categorylinks',
+ [ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
+ // Have to do that since Database::fieldNamesWithAlias treats numeric indexes
+ // as not being aliases, and NS_CATEGORY is numeric
+ [ '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 = [];
+ $id = $this->getId();
+
+ if ( $id == 0 ) {
+ return [];
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
+ [ 'cl_to' ],
+ [ '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;
+ }
+
+ /**
+ * 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.
+ *
+ * This should only be called from deferred updates or jobs to avoid contention.
+ *
+ * @param array $added The names of categories that were added
+ * @param array $deleted The names of categories that were deleted
+ * @param int $id Page ID (this should be the original deleted page ID)
+ */
+ public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
+ $id = $id ?: $this->getId();
+ $ns = $this->getTitle()->getNamespace();
+
+ $addFields = [ 'cat_pages = cat_pages + 1' ];
+ $removeFields = [ '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';
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ if ( count( $added ) ) {
+ $existingAdded = $dbw->selectFieldValues(
+ 'category',
+ 'cat_title',
+ [ 'cat_title' => $added ],
+ __METHOD__
+ );
+
+ // For category rows that already exist, do a plain
+ // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
+ // to avoid creating gaps in the cat_id sequence.
+ if ( count( $existingAdded ) ) {
+ $dbw->update(
+ 'category',
+ $addFields,
+ [ 'cat_title' => $existingAdded ],
+ __METHOD__
+ );
+ }
+
+ $missingAdded = array_diff( $added, $existingAdded );
+ if ( count( $missingAdded ) ) {
+ $insertRows = [];
+ foreach ( $missingAdded as $cat ) {
+ $insertRows[] = [
+ 'cat_title' => $cat,
+ 'cat_pages' => 1,
+ 'cat_subcats' => ( $ns == NS_CATEGORY ) ? 1 : 0,
+ 'cat_files' => ( $ns == NS_FILE ) ? 1 : 0,
+ ];
+ }
+ $dbw->upsert(
+ 'category',
+ $insertRows,
+ [ 'cat_title' ],
+ $addFields,
+ __METHOD__
+ );
+ }
+ }
+
+ if ( count( $deleted ) ) {
+ $dbw->update(
+ 'category',
+ $removeFields,
+ [ 'cat_title' => $deleted ],
+ __METHOD__
+ );
+ }
+
+ foreach ( $added as $catName ) {
+ $cat = Category::newFromName( $catName );
+ Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
+ }
+
+ foreach ( $deleted as $catName ) {
+ $cat = Category::newFromName( $catName );
+ Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
+ }
+
+ // Refresh counts on categories that should be empty now, to
+ // trigger possible deletion. Check master for the most
+ // up-to-date cat_pages.
+ if ( count( $deleted ) ) {
+ $rows = $dbw->select(
+ 'category',
+ [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ],
+ [ 'cat_title' => $deleted, 'cat_pages <= 0' ],
+ __METHOD__
+ );
+ foreach ( $rows as $row ) {
+ $cat = Category::newFromRow( $row );
+ // T166757: do the update after this DB commit
+ DeferredUpdates::addCallableUpdate( function () use ( $cat ) {
+ $cat->refreshCounts();
+ } );
+ }
+ }
+ }
+
+ /**
+ * Opportunistically enqueue link update jobs given fresh parser output if useful
+ *
+ * @param ParserOutput $parserOutput Current version page output
+ * @since 1.25
+ */
+ public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
+ if ( wfReadOnly() ) {
+ return;
+ }
+
+ if ( !Hooks::run( 'OpportunisticLinksUpdate',
+ [ $this, $this->mTitle, $parserOutput ]
+ ) ) {
+ return;
+ }
+
+ $config = RequestContext::getMain()->getConfig();
+
+ $params = [
+ 'isOpportunistic' => true,
+ 'rootJobTimestamp' => $parserOutput->getCacheTime()
+ ];
+
+ if ( $this->mTitle->areRestrictionsCascading() ) {
+ // If the page is cascade protecting, the links should really be up-to-date
+ JobQueueGroup::singleton()->lazyPush(
+ RefreshLinksJob::newPrioritized( $this->mTitle, $params )
+ );
+ } elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
+ // Assume the output contains "dynamic" time/random based magic words.
+ // Only update pages that expired due to dynamic content and NOT due to edits
+ // to referenced templates/files. When the cache expires due to dynamic content,
+ // page_touched is unchanged. We want to avoid triggering redundant jobs due to
+ // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
+ // template/file edit already triggered recursive RefreshLinksJob jobs.
+ if ( $this->getLinksTimestamp() > $this->getTouched() ) {
+ // If a page is uncacheable, do not keep spamming a job for it.
+ // Although it would be de-duplicated, it would still waste I/O.
+ $cache = ObjectCache::getLocalClusterInstance();
+ $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
+ $ttl = max( $parserOutput->getCacheExpiry(), 3600 );
+ if ( $cache->add( $key, time(), $ttl ) ) {
+ JobQueueGroup::singleton()->lazyPush(
+ RefreshLinksJob::newDynamic( $this->mTitle, $params )
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * 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 DeferrableUpdate[]
+ */
+ 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.
+ try {
+ $content = $this->getContent( Revision::RAW );
+ } catch ( Exception $ex ) {
+ // If we can't load the content, something is wrong. Perhaps that's why
+ // the user is trying to delete the page, so let's not fail in that case.
+ // Note that doDeleteArticleReal() will already have logged an issue with
+ // loading the content.
+ }
+ }
+
+ if ( !$content ) {
+ $updates = [];
+ } else {
+ $updates = $content->getDeletionUpdates( $this );
+ }
+
+ Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$updates ] );
+ return $updates;
+ }
+
+ /**
+ * Whether this content displayed on this page
+ * comes from the local database
+ *
+ * @since 1.28
+ * @return bool
+ */
+ public function isLocal() {
+ return true;
+ }
+
+ /**
+ * The display name for the site this content
+ * come from. If a subclass overrides isLocal(),
+ * this could return something other than the
+ * current site name
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getWikiDisplayName() {
+ global $wgSitename;
+ return $wgSitename;
+ }
+
+ /**
+ * Get the source URL for the content on this page,
+ * typically the canonical URL, but may be a remote
+ * link if the content comes from another site
+ *
+ * @since 1.28
+ * @return string
+ */
+ public function getSourceURL() {
+ return $this->getTitle()->getCanonicalURL();
+ }
+
+ /**
+ * @param WANObjectCache $cache
+ * @return string[]
+ * @since 1.28
+ */
+ public function getMutableCacheKeys( WANObjectCache $cache ) {
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+
+ return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() );
+ }
+}