]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - includes/diff/DifferenceEngine.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / diff / DifferenceEngine.php
index edf35a92f8605e73d0013aafe4534eaf3e6abcbc..a893fe88d16ce73907d63eefdbec1ad5e059865d 100644 (file)
 <?php
 /**
- * User interface for the difference engine
+ * User interface for the difference engine.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
  *
  * @file
  * @ingroup DifferenceEngine
  */
+use MediaWiki\MediaWikiServices;
 
-/**
- * Constant to indicate diff cache compatibility.
- * Bump this when changing the diff formatting in a way that
- * fixes important bugs or such to force cached diff views to
- * clear.
- */
+/** @deprecated use class constant instead */
 define( 'MW_DIFF_VERSION', '1.11a' );
 
 /**
  * @todo document
  * @ingroup DifferenceEngine
  */
-class DifferenceEngine {
-       /**#@+
-        * @private
+class DifferenceEngine extends ContextSource {
+       /**
+        * Constant to indicate diff cache compatibility.
+        * Bump this when changing the diff formatting in a way that
+        * fixes important bugs or such to force cached diff views to
+        * clear.
         */
-       var $mOldid, $mNewid, $mTitle;
-       var $mOldtitle, $mNewtitle, $mPagetitle;
-       var $mOldtext, $mNewtext;
-       var $mOldPage, $mNewPage;
-       var $mRcidMarkPatrolled;
-       var $mOldRev, $mNewRev;
-       var $mRevisionsLoaded = false; // Have the revisions been loaded
-       var $mTextLoaded = 0; // How many text blobs have been loaded, 0, 1 or 2?
-       var $mCacheHit = false; // Was the diff fetched from cache?
+       const DIFF_VERSION = MW_DIFF_VERSION;
+
+       /** @var int */
+       public $mOldid;
+
+       /** @var int */
+       public $mNewid;
+
+       private $mOldTags;
+       private $mNewTags;
+
+       /** @var Content */
+       public $mOldContent;
+
+       /** @var Content */
+       public $mNewContent;
+
+       /** @var Language */
+       protected $mDiffLang;
+
+       /** @var Title */
+       public $mOldPage;
+
+       /** @var Title */
+       public $mNewPage;
+
+       /** @var Revision */
+       public $mOldRev;
+
+       /** @var Revision */
+       public $mNewRev;
+
+       /** @var bool Have the revisions IDs been loaded */
+       private $mRevisionsIdsLoaded = false;
+
+       /** @var bool Have the revisions been loaded */
+       public $mRevisionsLoaded = false;
+
+       /** @var int How many text blobs have been loaded, 0, 1 or 2? */
+       public $mTextLoaded = 0;
+
+       /** @var bool Was the diff fetched from cache? */
+       public $mCacheHit = false;
 
        /**
         * Set this to true to add debug info to the HTML output.
         * Warning: this may cause RSS readers to spuriously mark articles as "new"
-        * (bug 20601)
+        * (T22601)
         */
-       var $enableDebugComment = false;
+       public $enableDebugComment = false;
 
-       // If true, line X is not displayed when X is 1, for example to increase
-       // readability and conserve space with many small diffs.
+       /** @var bool If true, line X is not displayed when X is 1, for example
+        *    to increase readability and conserve space with many small diffs.
+        */
        protected $mReducedLineNumbers = false;
 
-       protected $unhide = false; # show rev_deleted content if allowed
+       /** @var string Link to action=markpatrolled */
+       protected $mMarkPatrolledLink = null;
+
+       /** @var bool Show rev_deleted content if allowed */
+       protected $unhide = false;
+
+       /** @var bool Refresh the diff cache */
+       protected $mRefreshCache = false;
+
        /**#@-*/
 
        /**
-        * Constructor
-        * @param $titleObj Title object that the diff is associated with
-        * @param $old Integer: old ID we want to show and diff with.
-        * @param $new String: either 'prev' or 'next'.
-        * @param $rcid Integer: ??? FIXME (default 0)
-        * @param $refreshCache boolean If set, refreshes the diff cache
-        * @param $unhide boolean If set, allow viewing deleted revs
+        * @param IContextSource $context Context to use, anything else will be ignored
+        * @param int $old Old ID we want to show and diff with.
+        * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
+        * @param int $rcid Deprecated, no longer used!
+        * @param bool $refreshCache If set, refreshes the diff cache
+        * @param bool $unhide If set, allow viewing deleted revs
         */
-       function __construct( $titleObj = null, $old = 0, $new = 0, $rcid = 0,
-               $refreshCache = false, $unhide = false )
-       {
-               if ( $titleObj ) {
-                       $this->mTitle = $titleObj;
-               } else {
-                       global $wgTitle;
-                       $this->mTitle = $wgTitle;
+       public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
+               $refreshCache = false, $unhide = false
+       ) {
+               if ( $context instanceof IContextSource ) {
+                       $this->setContext( $context );
                }
+
                wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'\n" );
 
-               if ( 'prev' === $new ) {
-                       # Show diff between revision $old and the previous one.
-                       # Get previous one from DB.
-                       $this->mNewid = intval( $old );
-                       $this->mOldid = $this->mTitle->getPreviousRevisionID( $this->mNewid );
-               } elseif ( 'next' === $new ) {
-                       # Show diff between revision $old and the next one.
-                       # Get next one from DB.
-                       $this->mOldid = intval( $old );
-                       $this->mNewid = $this->mTitle->getNextRevisionID( $this->mOldid );
-                       if ( false === $this->mNewid ) {
-                               # if no result, NewId points to the newest old revision. The only newer
-                               # revision is cur, which is "0".
-                               $this->mNewid = 0;
-                       }
-               } else {
-                       $this->mOldid = intval( $old );
-                       $this->mNewid = intval( $new );
-                       wfRunHooks( 'NewDifferenceEngine', array( &$titleObj, &$this->mOldid, &$this->mNewid, $old, $new ) );
-               }
-               $this->mRcidMarkPatrolled = intval( $rcid );  # force it to be an integer
+               $this->mOldid = $old;
+               $this->mNewid = $new;
                $this->mRefreshCache = $refreshCache;
                $this->unhide = $unhide;
        }
 
-       function setReducedLineNumbers( $value = true ) {
+       /**
+        * @param bool $value
+        */
+       public function setReducedLineNumbers( $value = true ) {
                $this->mReducedLineNumbers = $value;
        }
 
-       function getTitle() {
-               return $this->mTitle;
+       /**
+        * @return Language
+        */
+       public function getDiffLang() {
+               if ( $this->mDiffLang === null ) {
+                       # Default language in which the diff text is written.
+                       $this->mDiffLang = $this->getTitle()->getPageLanguage();
+               }
+
+               return $this->mDiffLang;
        }
 
-       function wasCacheHit() {
+       /**
+        * @return bool
+        */
+       public function wasCacheHit() {
                return $this->mCacheHit;
        }
 
-       function getOldid() {
+       /**
+        * @return int
+        */
+       public function getOldid() {
+               $this->loadRevisionIds();
+
                return $this->mOldid;
        }
 
-       function getNewid() {
+       /**
+        * @return bool|int
+        */
+       public function getNewid() {
+               $this->loadRevisionIds();
+
                return $this->mNewid;
        }
 
-       function showDiffPage( $diffOnly = false ) {
-               global $wgUser, $wgOut, $wgUseExternalEditor, $wgUseRCPatrol;
-               wfProfileIn( __METHOD__ );
-
-               # Allow frames except in certain special cases
-               $wgOut->allowClickjacking();
-
-               # If external diffs are enabled both globally and for the user,
-               # we'll use the application/x-external-editor interface to call
-               # an external diff tool like kompare, kdiff3, etc.
-               if ( $wgUseExternalEditor && $wgUser->getOption( 'externaldiff' ) ) {
-                       global $wgInputEncoding, $wgServer, $wgScript, $wgLang;
-                       $wgOut->disable();
-                       header ( "Content-type: application/x-external-editor; charset=" . $wgInputEncoding );
-                       $url1 = $this->mTitle->getFullURL( array(
-                               'action' => 'raw',
-                               'oldid' => $this->mOldid
-                       ) );
-                       $url2 = $this->mTitle->getFullURL( array(
-                               'action' => 'raw',
-                               'oldid' => $this->mNewid
-                       ) );
-                       $special = $wgLang->getNsText( NS_SPECIAL );
-                       $control = <<<CONTROL
-                       [Process]
-                       Type=Diff text
-                       Engine=MediaWiki
-                       Script={$wgServer}{$wgScript}
-                       Special namespace={$special}
-
-                       [File]
-                       Extension=wiki
-                       URL=$url1
-
-                       [File 2]
-                       Extension=wiki
-                       URL=$url2
-CONTROL;
-                       echo( $control );
-
-                       wfProfileOut( __METHOD__ );
-                       return;
+       /**
+        * Look up a special:Undelete link to the given deleted revision id,
+        * as a workaround for being unable to load deleted diffs in currently.
+        *
+        * @param int $id Revision ID
+        *
+        * @return string|bool Link HTML or false
+        */
+       public function deletedLink( $id ) {
+               if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
+                       $dbr = wfGetDB( DB_REPLICA );
+                       $row = $dbr->selectRow( 'archive',
+                               array_merge(
+                                       Revision::selectArchiveFields(),
+                                       [ 'ar_namespace', 'ar_title' ]
+                               ),
+                               [ 'ar_rev_id' => $id ],
+                               __METHOD__ );
+                       if ( $row ) {
+                               $rev = Revision::newFromArchiveRow( $row );
+                               $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
+
+                               return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
+                                       'target' => $title->getPrefixedText(),
+                                       'timestamp' => $rev->getTimestamp()
+                               ] );
+                       }
                }
 
-               $wgOut->setArticleFlag( false );
-               if ( !$this->loadRevisionData() ) {
-                       $t = $this->mTitle->getPrefixedText();
-                       $d = wfMsgExt( 'missingarticle-diff', array( 'escape' ), $this->mOldid, $this->mNewid );
-                       $wgOut->setPagetitle( wfMsg( 'errorpagetitle' ) );
-                       $wgOut->addWikiMsg( 'missing-article', "<nowiki>$t</nowiki>", $d );
-                       wfProfileOut( __METHOD__ );
-                       return;
+               return false;
+       }
+
+       /**
+        * Build a wikitext link toward a deleted revision, if viewable.
+        *
+        * @param int $id Revision ID
+        *
+        * @return string Wikitext fragment
+        */
+       public function deletedIdMarker( $id ) {
+               $link = $this->deletedLink( $id );
+               if ( $link ) {
+                       return "[$link $id]";
+               } else {
+                       return (string)$id;
                }
+       }
 
-               wfRunHooks( 'DiffViewHeader', array( $this, $this->mOldRev, $this->mNewRev ) );
+       private function showMissingRevision() {
+               $out = $this->getOutput();
 
-               if ( $this->mNewRev->isCurrent() ) {
-                       $wgOut->setArticleFlag( true );
+               $missing = [];
+               if ( $this->mOldRev === null ||
+                       ( $this->mOldRev && $this->mOldContent === null )
+               ) {
+                       $missing[] = $this->deletedIdMarker( $this->mOldid );
+               }
+               if ( $this->mNewRev === null ||
+                       ( $this->mNewRev && $this->mNewContent === null )
+               ) {
+                       $missing[] = $this->deletedIdMarker( $this->mNewid );
                }
 
-               # mOldid is false if the difference engine is called with a "vague" query for
-               # a diff between a version V and its previous version V' AND the version V
-               # is the first version of that article. In that case, V' does not exist.
-               if ( $this->mOldid === false ) {
-                       $this->showFirstRevision();
-                       $this->renderNewRevision();  // should we respect $diffOnly here or not?
-                       wfProfileOut( __METHOD__ );
+               $out->setPageTitle( $this->msg( 'errorpagetitle' ) );
+               $msg = $this->msg( 'difference-missing-revision' )
+                       ->params( $this->getLanguage()->listToText( $missing ) )
+                       ->numParams( count( $missing ) )
+                       ->parseAsBlock();
+               $out->addHTML( $msg );
+       }
+
+       public function showDiffPage( $diffOnly = false ) {
+               # Allow frames except in certain special cases
+               $out = $this->getOutput();
+               $out->allowClickjacking();
+               $out->setRobotPolicy( 'noindex,nofollow' );
+
+               // Allow extensions to add any extra output here
+               Hooks::run( 'DifferenceEngineShowDiffPage', [ $out ] );
+
+               if ( !$this->loadRevisionData() ) {
+                       if ( Hooks::run( 'DifferenceEngineShowDiffPageMaybeShowMissingRevision', [ $this ] ) ) {
+                               $this->showMissingRevision();
+                       }
                        return;
                }
 
-               $wgOut->suppressQuickbar();
-
-               $oldTitle = $this->mOldPage->getPrefixedText();
-               $newTitle = $this->mNewPage->getPrefixedText();
-               if ( $oldTitle == $newTitle ) {
-                       $wgOut->setPageTitle( $newTitle );
-               } else {
-                       $wgOut->setPageTitle( $oldTitle . ', ' . $newTitle );
+               $user = $this->getUser();
+               $permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user );
+               if ( $this->mOldPage ) { # mOldPage might not be set, see below.
+                       $permErrors = wfMergeErrorArrays( $permErrors,
+                               $this->mOldPage->getUserPermissionsErrors( 'read', $user ) );
                }
-               if ( $this->mNewPage->equals( $this->mOldPage ) ) {
-                       $wgOut->setSubtitle( wfMsgExt( 'difference', array( 'parseinline' ) ) );
-               } else {
-                       $wgOut->setSubtitle( wfMsgExt( 'difference-multipage', array( 'parseinline' ) ) );
+               if ( count( $permErrors ) ) {
+                       throw new PermissionsError( 'read', $permErrors );
                }
-               $wgOut->setRobotPolicy( 'noindex,nofollow' );
 
-               if ( !$this->mOldPage->userCanRead() || !$this->mNewPage->userCanRead() ) {
-                       $wgOut->loginToUse();
-                       $wgOut->output();
-                       $wgOut->disable();
-                       wfProfileOut( __METHOD__ );
-                       return;
+               $rollback = '';
+
+               $query = [];
+               # Carry over 'diffonly' param via navigation links
+               if ( $diffOnly != $user->getBoolOption( 'diffonly' ) ) {
+                       $query['diffonly'] = $diffOnly;
+               }
+               # Cascade unhide param in links for easy deletion browsing
+               if ( $this->unhide ) {
+                       $query['unhide'] = 1;
                }
 
-               $sk = $wgUser->getSkin();
+               # Check if one of the revisions is deleted/suppressed
+               $deleted = $suppressed = false;
+               $allowed = $this->mNewRev->userCan( Revision::DELETED_TEXT, $user );
+
+               $revisionTools = [];
 
-               // Check if page is editable
-               $editable = $this->mNewRev->getTitle()->userCan( 'edit' );
-               if ( $editable && $this->mNewRev->isCurrent() && $wgUser->isAllowed( 'rollback' ) ) {
-                       $wgOut->preventClickjacking();
-                       $rollback = '&#160;&#160;&#160;' . $sk->generateRollback( $this->mNewRev );
+               # mOldRev is false if the difference engine is called with a "vague" query for
+               # a diff between a version V and its previous version V' AND the version V
+               # is the first version of that article. In that case, V' does not exist.
+               if ( $this->mOldRev === false ) {
+                       $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
+                       $samePage = true;
+                       $oldHeader = '';
+                       // Allow extensions to change the $oldHeader variable
+                       Hooks::run( 'DifferenceEngineOldHeaderNoOldRev', [ &$oldHeader ] );
                } else {
-                       $rollback = '';
-               }
+                       Hooks::run( 'DiffViewHeader', [ $this, $this->mOldRev, $this->mNewRev ] );
 
-               // Prepare a change patrol link, if applicable
-               if ( $wgUseRCPatrol && $this->mTitle->userCan( 'patrol' ) ) {
-                       // If we've been given an explicit change identifier, use it; saves time
-                       if ( $this->mRcidMarkPatrolled ) {
-                               $rcid = $this->mRcidMarkPatrolled;
-                               $rc = RecentChange::newFromId( $rcid );
-                               // Already patrolled?
-                               $rcid = is_object( $rc ) && !$rc->getAttribute( 'rc_patrolled' ) ? $rcid : 0;
+                       if ( $this->mNewPage->equals( $this->mOldPage ) ) {
+                               $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) );
+                               $samePage = true;
                        } else {
-                               // Look for an unpatrolled change corresponding to this diff
-                               $db = wfGetDB( DB_SLAVE );
-                               $change = RecentChange::newFromConds(
-                                       array(
-                                       // Redundant user,timestamp condition so we can use the existing index
-                                               'rc_user_text'  => $this->mNewRev->getRawUserText(),
-                                               'rc_timestamp'  => $db->timestamp( $this->mNewRev->getTimestamp() ),
-                                               'rc_this_oldid' => $this->mNewid,
-                                               'rc_last_oldid' => $this->mOldid,
-                                               'rc_patrolled'  => 0
-                                       ),
-                                       __METHOD__
-                               );
-                               if ( $change instanceof RecentChange ) {
-                                       $rcid = $change->mAttribs['rc_id'];
-                                       $this->mRcidMarkPatrolled = $rcid;
-                               } else {
-                                       // None found
-                                       $rcid = 0;
+                               $out->setPageTitle( $this->msg( 'difference-title-multipage',
+                                       $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
+                               $out->addSubtitle( $this->msg( 'difference-multipage' ) );
+                               $samePage = false;
+                       }
+
+                       if ( $samePage && $this->mNewPage->quickUserCan( 'edit', $user ) ) {
+                               if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) {
+                                       $rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() );
+                                       if ( $rollbackLink ) {
+                                               $out->preventClickjacking();
+                                               $rollback = '&#160;&#160;&#160;' . $rollbackLink;
+                                       }
+                               }
+
+                               if ( !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) &&
+                                       !$this->mNewRev->isDeleted( Revision::DELETED_TEXT )
+                               ) {
+                                       $undoLink = Html::element( 'a', [
+                                                       'href' => $this->mNewPage->getLocalURL( [
+                                                               'action' => 'edit',
+                                                               'undoafter' => $this->mOldid,
+                                                               'undo' => $this->mNewid
+                                                       ] ),
+                                                       'title' => Linker::titleAttrib( 'undo' ),
+                                               ],
+                                               $this->msg( 'editundo' )->text()
+                                       );
+                                       $revisionTools['mw-diff-undo'] = $undoLink;
                                }
                        }
-                       // Build the link
-                       if ( $rcid ) {
-                               $wgOut->preventClickjacking();
-                               $token = $wgUser->editToken( $rcid );
-                               $patrol = ' <span class="patrollink">[' . $sk->link(
-                                       $this->mTitle,
-                                       wfMsgHtml( 'markaspatrolleddiff' ),
-                                       array(),
-                                       array(
-                                               'action' => 'markpatrolled',
-                                               'rcid' => $rcid,
-                                               'token' => $token,
-                                       ),
-                                       array(
-                                               'known',
-                                               'noclasses'
-                                       )
-                               ) . ']</span>';
+
+                       # Make "previous revision link"
+                       if ( $samePage && $this->mOldRev->getPrevious() ) {
+                               $prevlink = Linker::linkKnown(
+                                       $this->mOldPage,
+                                       $this->msg( 'previousdiff' )->escaped(),
+                                       [ 'id' => 'differences-prevlink' ],
+                                       [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query
+                               );
                        } else {
-                               $patrol = '';
+                               $prevlink = '&#160;';
                        }
-               } else {
-                       $patrol = '';
-               }
 
-               # Carry over 'diffonly' param via navigation links
-               if ( $diffOnly != $wgUser->getBoolOption( 'diffonly' ) ) {
-                       $query['diffonly'] = $diffOnly;
-               }
+                       if ( $this->mOldRev->isMinor() ) {
+                               $oldminor = ChangesList::flag( 'minor' );
+                       } else {
+                               $oldminor = '';
+                       }
 
-               # Make "previous revision link"
-               $query['diff'] = 'prev';
-               $query['oldid'] = $this->mOldid;
-               # Cascade unhide param in links for easy deletion browsing
-               if ( $this->unhide ) {
-                       $query['unhide'] = 1;
-               }
-               if ( !$this->mOldRev->getPrevious() ) {
-                       $prevlink = '&#160;';
-               } else {
-                       $prevlink = $sk->link(
-                               $this->mTitle,
-                               wfMsgHtml( 'previousdiff' ),
-                               array(
-                                       'id' => 'differences-prevlink'
-                               ),
-                               $query,
-                               array(
-                                       'known',
-                                       'noclasses'
-                               )
-                       );
+                       $ldel = $this->revisionDeleteLink( $this->mOldRev );
+                       $oldRevisionHeader = $this->getRevisionHeader( $this->mOldRev, 'complete' );
+                       $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
+
+                       $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
+                               '<div id="mw-diff-otitle2">' .
+                               Linker::revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
+                               '<div id="mw-diff-otitle3">' . $oldminor .
+                               Linker::revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
+                               '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
+                               '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
+
+                       // Allow extensions to change the $oldHeader variable
+                       Hooks::run( 'DifferenceEngineOldHeader', [ $this, &$oldHeader, $prevlink, $oldminor,
+                               $diffOnly, $ldel, $this->unhide ] );
+
+                       if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
+                               $deleted = true; // old revisions text is hidden
+                               if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+                                       $suppressed = true; // also suppressed
+                               }
+                       }
+
+                       # Check if this user can see the revisions
+                       if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT, $user ) ) {
+                               $allowed = false;
+                       }
                }
 
+               $out->addJsConfigVars( [
+                       'wgDiffOldId' => $this->mOldid,
+                       'wgDiffNewId' => $this->mNewid,
+               ] );
+
                # Make "next revision link"
-               $query['diff'] = 'next';
-               $query['oldid'] = $this->mNewid;
                # Skip next link on the top revision
-               if ( $this->mNewRev->isCurrent() ) {
-                       $nextlink = '&#160;';
-               } else {
-                       $nextlink = $sk->link(
-                               $this->mTitle,
-                               wfMsgHtml( 'nextdiff' ),
-                               array(
-                                       'id' => 'differences-nextlink'
-                               ),
-                               $query,
-                               array(
-                                       'known',
-                                       'noclasses'
-                               )
+               if ( $samePage && !$this->mNewRev->isCurrent() ) {
+                       $nextlink = Linker::linkKnown(
+                               $this->mNewPage,
+                               $this->msg( 'nextdiff' )->escaped(),
+                               [ 'id' => 'differences-nextlink' ],
+                               [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query
                        );
+               } else {
+                       $nextlink = '&#160;';
                }
 
-               $oldminor = '';
-               $newminor = '';
-
-               if ( $this->mOldRev->isMinor() ) {
-                       $oldminor = ChangesList::flag( 'minor' );
-               }
                if ( $this->mNewRev->isMinor() ) {
                        $newminor = ChangesList::flag( 'minor' );
+               } else {
+                       $newminor = '';
                }
 
                # Handle RevisionDelete links...
-               $ldel = $this->revisionDeleteLink( $this->mOldRev );
                $rdel = $this->revisionDeleteLink( $this->mNewRev );
 
-               $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $this->mOldtitle . '</strong></div>' .
-                       '<div id="mw-diff-otitle2">' .
-                               $sk->revUserTools( $this->mOldRev, !$this->unhide ) . '</div>' .
-                       '<div id="mw-diff-otitle3">' . $oldminor .
-                               $sk->revComment( $this->mOldRev, !$diffOnly, !$this->unhide ) . $ldel . '</div>' .
-                       '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
-               $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $this->mNewtitle . '</strong></div>' .
-                       '<div id="mw-diff-ntitle2">' . $sk->revUserTools( $this->mNewRev, !$this->unhide ) .
-                               " $rollback</div>" .
+               # Allow extensions to define their own revision tools
+               Hooks::run( 'DiffRevisionTools',
+                       [ $this->mNewRev, &$revisionTools, $this->mOldRev, $user ] );
+               $formattedRevisionTools = [];
+               // Put each one in parentheses (poor man's button)
+               foreach ( $revisionTools as $key => $tool ) {
+                       $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
+                       $element = Html::rawElement(
+                               'span',
+                               [ 'class' => $toolClass ],
+                               $this->msg( 'parentheses' )->rawParams( $tool )->escaped()
+                       );
+                       $formattedRevisionTools[] = $element;
+               }
+               $newRevisionHeader = $this->getRevisionHeader( $this->mNewRev, 'complete' ) .
+                       ' ' . implode( ' ', $formattedRevisionTools );
+               $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
+
+               $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
+                       '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $this->mNewRev, !$this->unhide ) .
+                       " $rollback</div>" .
                        '<div id="mw-diff-ntitle3">' . $newminor .
-                               $sk->revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
-                       '<div id="mw-diff-ntitle4">' . $nextlink . $patrol . '</div>';
+                       Linker::revComment( $this->mNewRev, !$diffOnly, !$this->unhide ) . $rdel . '</div>' .
+                       '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
+                       '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
+
+               // Allow extensions to change the $newHeader variable
+               Hooks::run( 'DifferenceEngineNewHeader', [ $this, &$newHeader, $formattedRevisionTools,
+                       $nextlink, $rollback, $newminor, $diffOnly, $rdel, $this->unhide ] );
 
-               # Check if this user can see the revisions
-               $allowed = $this->mOldRev->userCan( Revision::DELETED_TEXT )
-                       && $this->mNewRev->userCan( Revision::DELETED_TEXT );
-               # Check if one of the revisions is deleted/suppressed
-               $deleted = $suppressed = false;
-               if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
-                       $deleted = true; // old revisions text is hidden
-                       if ( $this->mOldRev->isDeleted( Revision::DELETED_RESTRICTED ) )
-                               $suppressed = true; // also suppressed
-               }
                if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
                        $deleted = true; // new revisions text is hidden
-                       if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) )
+                       if ( $this->mNewRev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
                                $suppressed = true; // also suppressed
+                       }
                }
+
                # If the diff cannot be shown due to a deleted revision, then output
                # the diff header and links to unhide (if available)...
                if ( $deleted && ( !$this->unhide || !$allowed ) ) {
                        $this->showDiffStyle();
                        $multi = $this->getMultiNotice();
-                       $wgOut->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
+                       $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
                        if ( !$allowed ) {
                                $msg = $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff';
                                # Give explanation for why revision is not visible
-                               $wgOut->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
-                                       array( $msg ) );
+                               $out->wrapWikiMsg( "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
+                                       [ $msg ] );
                        } else {
                                # Give explanation and add a link to view the diff...
-                               $link = $this->mTitle->getFullUrl( array(
-                                       'diff' => $this->mNewid,
-                                       'oldid' => $this->mOldid,
-                                       'unhide' => 1
-                               ) );
+                               $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
+                               $link = $this->getTitle()->getFullURL( $query );
                                $msg = $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff';
-                               $wgOut->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", array( $msg, $link ) );
+                               $out->wrapWikiMsg(
+                                       "<div id='mw-$msg' class='mw-warning plainlinks'>\n$1\n</div>\n",
+                                       [ $msg, $link ]
+                               );
                        }
                # Otherwise, output a regular diff...
                } else {
@@ -390,226 +471,250 @@ CONTROL;
                        $notice = '';
                        if ( $deleted ) {
                                $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
-                               $notice = "<div class='mw-warning plainlinks'>\n" . wfMsgExt( $msg, 'parseinline' ) . "</div>\n";
+                               $notice = "<div id='mw-$msg' class='mw-warning plainlinks'>\n" .
+                                       $this->msg( $msg )->parse() .
+                                       "</div>\n";
                        }
                        $this->showDiff( $oldHeader, $newHeader, $notice );
                        if ( !$diffOnly ) {
                                $this->renderNewRevision();
                        }
                }
-               wfProfileOut( __METHOD__ );
        }
 
-       protected function revisionDeleteLink( $rev ) {
-               global $wgUser;
-               $link = '';
-               $canHide = $wgUser->isAllowed( 'deleterevision' );
-               // Show del/undel link if:
-               // (a) the user can delete revisions, or
-               // (b) the user can view deleted revision *and* this one is deleted
-               if ( $canHide || ( $rev->getVisibility() && $wgUser->isAllowed( 'deletedhistory' ) ) ) {
-                       $sk = $wgUser->getSkin();
-                       if ( !$rev->userCan( Revision::DELETED_RESTRICTED ) ) {
-                               $link = $sk->revDeleteLinkDisabled( $canHide ); // revision was hidden from sysops
+       /**
+        * Build a link to mark a change as patrolled.
+        *
+        * Returns empty string if there's either no revision to patrol or the user is not allowed to.
+        * Side effect: When the patrol link is build, this method will call
+        * OutputPage::preventClickjacking() and load mediawiki.page.patrol.ajax.
+        *
+        * @return string HTML or empty string
+        */
+       public function markPatrolledLink() {
+               if ( $this->mMarkPatrolledLink === null ) {
+                       $linkInfo = $this->getMarkPatrolledLinkInfo();
+                       // If false, there is no patrol link needed/allowed
+                       if ( !$linkInfo ) {
+                               $this->mMarkPatrolledLink = '';
                        } else {
-                               $query = array(
-                                       'type'   => 'revision',
-                                       'target' => $rev->mTitle->getPrefixedDbkey(),
-                                       'ids'    => $rev->getId()
-                               );
-                               $link = $sk->revDeleteLink( $query,
-                                       $rev->isDeleted( Revision::DELETED_RESTRICTED ), $canHide );
+                               $this->mMarkPatrolledLink = ' <span class="patrollink" data-mw="interface">[' .
+                                       Linker::linkKnown(
+                                               $this->mNewPage,
+                                               $this->msg( 'markaspatrolleddiff' )->escaped(),
+                                               [],
+                                               [
+                                                       'action' => 'markpatrolled',
+                                                       'rcid' => $linkInfo['rcid'],
+                                               ]
+                                       ) . ']</span>';
+                               // Allow extensions to change the markpatrolled link
+                               Hooks::run( 'DifferenceEngineMarkPatrolledLink', [ $this,
+                                       &$this->mMarkPatrolledLink, $linkInfo['rcid'] ] );
                        }
-                       $link = '&#160;&#160;&#160;' . $link . ' ';
                }
-               return $link;
+               return $this->mMarkPatrolledLink;
        }
 
        /**
-        * Show the new revision of the page.
+        * Returns an array of meta data needed to build a "mark as patrolled" link and
+        * adds the mediawiki.page.patrol.ajax to the output.
+        *
+        * @return array|false An array of meta data for a patrol link (rcid only)
+        *  or false if no link is needed
         */
-       function renderNewRevision() {
-               global $wgOut, $wgUser;
-               wfProfileIn( __METHOD__ );
-
-               $wgOut->addHTML( "<hr /><h2>{$this->mPagetitle}</h2>\n" );
-               # Add deleted rev tag if needed
-               if ( !$this->mNewRev->userCan( Revision::DELETED_TEXT ) ) {
-                       $wgOut->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", 'rev-deleted-text-permission' );
-               } else if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
-                       $wgOut->wrapWikiMsg( "<div class='mw-warning plainlinks'>\n$1\n</div>\n", 'rev-deleted-text-view' );
-               }
-
-               $pCache = true;
-               if ( !$this->mNewRev->isCurrent() ) {
-                       $oldEditSectionSetting = $wgOut->parserOptions()->setEditSection( false );
-                       $pCache = false;
-               }
-
-               $this->loadNewText();
-               if ( is_object( $this->mNewRev ) ) {
-                       $wgOut->setRevisionId( $this->mNewRev->getId() );
-               }
-
-               if ( $this->mTitle->isCssJsSubpage() || $this->mTitle->isCssOrJsPage() ) {
-                       // Stolen from Article::view --AG 2007-10-11
-                       // Give hooks a chance to customise the output
-                       if ( wfRunHooks( 'ShowRawCssJs', array( $this->mNewtext, $this->mTitle, $wgOut ) ) ) {
-                               // Wrap the whole lot in a <pre> and don't parse
-                               $m = array();
-                               preg_match( '!\.(css|js)$!u', $this->mTitle->getText(), $m );
-                               $wgOut->addHTML( "<pre class=\"mw-code mw-{$m[1]}\" dir=\"ltr\">\n" );
-                               $wgOut->addHTML( htmlspecialchars( $this->mNewtext ) );
-                               $wgOut->addHTML( "\n</pre>\n" );
+       protected function getMarkPatrolledLinkInfo() {
+               global $wgUseRCPatrol, $wgEnableAPI, $wgEnableWriteAPI;
+
+               $user = $this->getUser();
+
+               // Prepare a change patrol link, if applicable
+               if (
+                       // Is patrolling enabled and the user allowed to?
+                       $wgUseRCPatrol && $this->mNewPage->quickUserCan( 'patrol', $user ) &&
+                       // Only do this if the revision isn't more than 6 hours older
+                       // than the Max RC age (6h because the RC might not be cleaned out regularly)
+                       RecentChange::isInRCLifespan( $this->mNewRev->getTimestamp(), 21600 )
+               ) {
+                       // Look for an unpatrolled change corresponding to this diff
+                       $db = wfGetDB( DB_REPLICA );
+                       $change = RecentChange::newFromConds(
+                               [
+                                       'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ),
+                                       'rc_this_oldid' => $this->mNewid,
+                                       'rc_patrolled' => 0
+                               ],
+                               __METHOD__
+                       );
+
+                       if ( $change && !$change->getPerformer()->equals( $user ) ) {
+                               $rcid = $change->getAttribute( 'rc_id' );
+                       } else {
+                               // None found or the page has been created by the current user.
+                               // If the user could patrol this it already would be patrolled
+                               $rcid = 0;
                        }
-               } elseif ( wfRunHooks( 'ArticleContentOnDiff', array( $this, $wgOut ) ) ) {
-                       if ( $pCache ) {
-                               $article = new Article( $this->mTitle, 0 );
-                               $pOutput = ParserCache::singleton()->get( $article, $wgOut->parserOptions() );
-                               if ( $pOutput ) {
-                                       $wgOut->addParserOutput( $pOutput );
-                               } else {
-                                       $article->doViewParse();
+
+                       // Allow extensions to possibly change the rcid here
+                       // For example the rcid might be set to zero due to the user
+                       // being the same as the performer of the change but an extension
+                       // might still want to show it under certain conditions
+                       Hooks::run( 'DifferenceEngineMarkPatrolledRCID', [ &$rcid, $this, $change, $user ] );
+
+                       // Build the link
+                       if ( $rcid ) {
+                               $this->getOutput()->preventClickjacking();
+                               if ( $wgEnableAPI && $wgEnableWriteAPI
+                                       && $user->isAllowed( 'writeapi' )
+                               ) {
+                                       $this->getOutput()->addModules( 'mediawiki.page.patrol.ajax' );
                                }
-                       } else {
-                               $wgOut->addWikiTextTidy( $this->mNewtext );
+
+                               return [
+                                       'rcid' => $rcid,
+                               ];
                        }
                }
 
-               if ( is_object( $this->mNewRev ) && !$this->mNewRev->isCurrent() ) {
-                       $wgOut->parserOptions()->setEditSection( $oldEditSectionSetting );
-               }
+               // No mark as patrolled link applicable
+               return false;
+       }
 
-               # Add redundant patrol link on bottom...
-               if ( $this->mRcidMarkPatrolled && $this->mTitle->quickUserCan( 'patrol' ) ) {
-                       $sk = $wgUser->getSkin();
-                       $token = $wgUser->editToken( $this->mRcidMarkPatrolled );
-                       $wgOut->preventClickjacking();
-                       $wgOut->addHTML(
-                               "<div class='patrollink'>[" . $sk->link(
-                                       $this->mTitle,
-                                       wfMsgHtml( 'markaspatrolleddiff' ),
-                                       array(),
-                                       array(
-                                               'action' => 'markpatrolled',
-                                               'rcid' => $this->mRcidMarkPatrolled,
-                                               'token' => $token,
-                                       )
-                               ) . ']</div>'
-                        );
+       /**
+        * @param Revision $rev
+        *
+        * @return string
+        */
+       protected function revisionDeleteLink( $rev ) {
+               $link = Linker::getRevDeleteLink( $this->getUser(), $rev, $rev->getTitle() );
+               if ( $link !== '' ) {
+                       $link = '&#160;&#160;&#160;' . $link . ' ';
                }
 
-               wfProfileOut( __METHOD__ );
+               return $link;
        }
 
        /**
-        * Show the first revision of an article. Uses normal diff headers in
-        * contrast to normal "old revision" display style.
+        * Show the new revision of the page.
         */
-       function showFirstRevision() {
-               global $wgOut, $wgUser;
-               wfProfileIn( __METHOD__ );
-
-               # Get article text from the DB
-               #
-               if ( ! $this->loadNewText() ) {
-                       $t = $this->mTitle->getPrefixedText();
-                       $d = wfMsgExt( 'missingarticle-diff', array( 'escape' ), $this->mOldid, $this->mNewid );
-                       $wgOut->setPagetitle( wfMsg( 'errorpagetitle' ) );
-                       $wgOut->addWikiMsg( 'missing-article', "<nowiki>$t</nowiki>", $d );
-                       wfProfileOut( __METHOD__ );
-                       return;
-               }
-               if ( $this->mNewRev->isCurrent() ) {
-                       $wgOut->setArticleFlag( true );
+       public function renderNewRevision() {
+               $out = $this->getOutput();
+               $revHeader = $this->getRevisionHeader( $this->mNewRev );
+               # Add "current version as of X" title
+               $out->addHTML( "<hr class='diff-hr' id='mw-oldid' />
+               <h2 class='diff-currentversion-title'>{$revHeader}</h2>\n" );
+               # Page content may be handled by a hooked call instead...
+               # @codingStandardsIgnoreStart Ignoring long lines.
+               if ( Hooks::run( 'ArticleContentOnDiff', [ $this, $out ] ) ) {
+                       $this->loadNewText();
+                       $out->setRevisionId( $this->mNewid );
+                       $out->setRevisionTimestamp( $this->mNewRev->getTimestamp() );
+                       $out->setArticleFlag( true );
+
+                       if ( !Hooks::run( 'ArticleContentViewCustom', [ $this->mNewContent, $this->mNewPage, $out ] ) ) {
+                               // Handled by extension
+                       } else {
+                               // Normal page
+                               if ( $this->getTitle()->equals( $this->mNewPage ) ) {
+                                       // If the Title stored in the context is the same as the one
+                                       // of the new revision, we can use its associated WikiPage
+                                       // object.
+                                       $wikiPage = $this->getWikiPage();
+                               } else {
+                                       // Otherwise we need to create our own WikiPage object
+                                       $wikiPage = WikiPage::factory( $this->mNewPage );
+                               }
+
+                               $parserOutput = $this->getParserOutput( $wikiPage, $this->mNewRev );
+
+                               # WikiPage::getParserOutput() should not return false, but just in case
+                               if ( $parserOutput ) {
+                                       // Allow extensions to change parser output here
+                                       if ( Hooks::run( 'DifferenceEngineRenderRevisionAddParserOutput', [ $this, $out, $parserOutput, $wikiPage ] ) ) {
+                                               $out->addParserOutput( $parserOutput );
+                                       }
+                               }
+                       }
                }
+               # @codingStandardsIgnoreEnd
 
-               # Check if user is allowed to look at this page. If not, bail out.
-               #
-               if ( !$this->mTitle->userCanRead() ) {
-                       $wgOut->loginToUse();
-                       $wgOut->output();
-                       wfProfileOut( __METHOD__ );
-                       throw new MWException( "Permission Error: you do not have access to view this page" );
+               // Allow extensions to optionally not show the final patrolled link
+               if ( Hooks::run( 'DifferenceEngineRenderRevisionShowFinalPatrolLink' ) ) {
+                       # Add redundant patrol link on bottom...
+                       $out->addHTML( $this->markPatrolledLink() );
                }
+       }
 
-               # Prepare the header box
-               #
-               $sk = $wgUser->getSkin();
+       protected function getParserOutput( WikiPage $page, Revision $rev ) {
+               $parserOptions = $page->makeParserOptions( $this->getContext() );
 
-               $next = $this->mTitle->getNextRevisionID( $this->mNewid );
-               if ( !$next ) {
-                       $nextlink = '';
-               } else {
-                       $nextlink = '<br />' . $sk->link(
-                               $this->mTitle,
-                               wfMsgHtml( 'nextdiff' ),
-                               array(
-                                       'id' => 'differences-nextlink'
-                               ),
-                               array(
-                                       'diff' => 'next',
-                                       'oldid' => $this->mNewid,
-                               ),
-                               array(
-                                       'known',
-                                       'noclasses'
-                               )
-                       );
+               if ( !$rev->isCurrent() || !$rev->getTitle()->quickUserCan( 'edit', $this->getUser() ) ) {
+                       $parserOptions->setEditSection( false );
                }
-               $header = "<div class=\"firstrevisionheader\" style=\"text-align: center\">" .
-                       $sk->revUserTools( $this->mNewRev ) . "<br />" . $sk->revComment( $this->mNewRev ) . $nextlink . "</div>\n";
 
-               $wgOut->addHTML( $header );
+               $parserOutput = $page->getParserOutput( $parserOptions, $rev->getId() );
 
-               $wgOut->setSubtitle( wfMsgExt( 'difference', array( 'parseinline' ) ) );
-               $wgOut->setRobotPolicy( 'noindex,nofollow' );
-
-               wfProfileOut( __METHOD__ );
+               return $parserOutput;
        }
 
        /**
-        * Get the diff text, send it to $wgOut
+        * Get the diff text, send it to the OutputPage object
         * Returns false if the diff could not be generated, otherwise returns true
+        *
+        * @param string|bool $otitle Header for old text or false
+        * @param string|bool $ntitle Header for new text or false
+        * @param string $notice HTML between diff header and body
+        *
+        * @return bool
         */
-       function showDiff( $otitle, $ntitle, $notice = '' ) {
-               global $wgOut;
+       public function showDiff( $otitle, $ntitle, $notice = '' ) {
+               // Allow extensions to affect the output here
+               Hooks::run( 'DifferenceEngineShowDiff', [ $this ] );
+
                $diff = $this->getDiff( $otitle, $ntitle, $notice );
                if ( $diff === false ) {
-                       $wgOut->addWikiMsg( 'missing-article', "<nowiki>(fixme, bug)</nowiki>", '' );
+                       $this->showMissingRevision();
+
                        return false;
                } else {
                        $this->showDiffStyle();
-                       $wgOut->addHTML( $diff );
+                       $this->getOutput()->addHTML( $diff );
+
                        return true;
                }
        }
 
        /**
-        * Add style sheets and supporting JS for diff display.
+        * Add style sheets for diff display.
         */
-       function showDiffStyle() {
-               global $wgOut;
-               $wgOut->addModuleStyles( 'mediawiki.legacy.diff' );
-               $wgOut->addModuleScripts( 'mediawiki.legacy.diff' );
+       public function showDiffStyle() {
+               $this->getOutput()->addModuleStyles( 'mediawiki.diff.styles' );
        }
 
        /**
         * Get complete diff table, including header
         *
-        * @param $otitle Title: old title
-        * @param $ntitle Title: new title
-        * @param $notice String: HTML between diff header and body
+        * @param string|bool $otitle Header for old text or false
+        * @param string|bool $ntitle Header for new text or false
+        * @param string $notice HTML between diff header and body
+        *
         * @return mixed
         */
-       function getDiff( $otitle, $ntitle, $notice = '' ) {
+       public function getDiff( $otitle, $ntitle, $notice = '' ) {
                $body = $this->getDiffBody();
                if ( $body === false ) {
                        return false;
-               } else {
-                       $multi = $this->getMultiNotice();
-                       return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
                }
+
+               $multi = $this->getMultiNotice();
+               // Display a message when the diff is empty
+               if ( $body === '' ) {
+                       $notice .= '<div class="mw-diff-empty">' .
+                               $this->msg( 'diff-empty' )->parse() .
+                               "</div>\n";
+               }
+
+               return $this->addHeader( $body, $otitle, $ntitle, $multi, $notice );
        }
 
        /**
@@ -618,40 +723,41 @@ CONTROL;
         * @return mixed (string/false)
         */
        public function getDiffBody() {
-               global $wgMemc;
-               wfProfileIn( __METHOD__ );
                $this->mCacheHit = true;
                // Check if the diff should be hidden from this user
                if ( !$this->loadRevisionData() ) {
-                       wfProfileOut( __METHOD__ );
                        return false;
-               } elseif ( $this->mOldRev && !$this->mOldRev->userCan( Revision::DELETED_TEXT ) ) {
-                       wfProfileOut( __METHOD__ );
+               } elseif ( $this->mOldRev &&
+                       !$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+               ) {
                        return false;
-               } elseif ( $this->mNewRev && !$this->mNewRev->userCan( Revision::DELETED_TEXT ) ) {
-                       wfProfileOut( __METHOD__ );
+               } elseif ( $this->mNewRev &&
+                       !$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
+               ) {
                        return false;
                }
                // Short-circuit
-               if ( $this->mOldRev && $this->mNewRev
-                       && $this->mOldRev->getID() == $this->mNewRev->getID() )
-               {
-                       wfProfileOut( __METHOD__ );
-                       return '';
+               if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev
+                       && $this->mOldRev->getId() == $this->mNewRev->getId() )
+               ) {
+                       if ( Hooks::run( 'DifferenceEngineShowEmptyOldContent', [ $this ] ) ) {
+                               return '';
+                       }
                }
                // Cacheable?
                $key = false;
+               $cache = ObjectCache::getMainWANInstance();
                if ( $this->mOldid && $this->mNewid ) {
-                       $key = wfMemcKey( 'diff', 'version', MW_DIFF_VERSION,
-                               'oldid', $this->mOldid, 'newid', $this->mNewid );
+                       $key = $this->getDiffBodyCacheKey();
+
                        // Try cache
                        if ( !$this->mRefreshCache ) {
-                               $difftext = $wgMemc->get( $key );
+                               $difftext = $cache->get( $key );
                                if ( $difftext ) {
-                                       wfIncrStats( 'diff_cache_hit' );
+                                       wfIncrStats( 'diff_cache.hit' );
                                        $difftext = $this->localiseLineNumbers( $difftext );
                                        $difftext .= "\n<!-- diff cache key $key -->\n";
-                                       wfProfileOut( __METHOD__ );
+
                                        return $difftext;
                                }
                        } // don't try to load but save the result
@@ -660,97 +766,200 @@ CONTROL;
 
                // Loadtext is permission safe, this just clears out the diff
                if ( !$this->loadText() ) {
-                       wfProfileOut( __METHOD__ );
                        return false;
                }
 
-               $difftext = $this->generateDiffBody( $this->mOldtext, $this->mNewtext );
+               $difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent );
+
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $diffEngine = $this;
 
                // Save to cache for 7 days
-               if ( !wfRunHooks( 'AbortDiffCache', array( &$this ) ) ) {
-                       wfIncrStats( 'diff_uncacheable' );
+               if ( !Hooks::run( 'AbortDiffCache', [ &$diffEngine ] ) ) {
+                       wfIncrStats( 'diff_cache.uncacheable' );
                } elseif ( $key !== false && $difftext !== false ) {
-                       wfIncrStats( 'diff_cache_miss' );
-                       $wgMemc->set( $key, $difftext, 7 * 86400 );
+                       wfIncrStats( 'diff_cache.miss' );
+                       $cache->set( $key, $difftext, 7 * 86400 );
                } else {
-                       wfIncrStats( 'diff_uncacheable' );
+                       wfIncrStats( 'diff_cache.uncacheable' );
                }
                // Replace line numbers with the text in the user's language
                if ( $difftext !== false ) {
                        $difftext = $this->localiseLineNumbers( $difftext );
                }
-               wfProfileOut( __METHOD__ );
+
                return $difftext;
        }
 
        /**
-        * Make sure the proper modules are loaded before we try to
-        * make the diff
+        * Returns the cache key for diff body text or content.
+        *
+        * @since 1.23
+        *
+        * @throws MWException
+        * @return string
+        */
+       protected function getDiffBodyCacheKey() {
+               if ( !$this->mOldid || !$this->mNewid ) {
+                       throw new MWException( 'mOldid and mNewid must be set to get diff cache key.' );
+               }
+
+               return wfMemcKey( 'diff', 'version', self::DIFF_VERSION,
+                       'oldid', $this->mOldid, 'newid', $this->mNewid );
+       }
+
+       /**
+        * Generate a diff, no caching.
+        *
+        * This implementation uses generateTextDiffBody() to generate a diff based on the default
+        * serialization of the given Content objects. This will fail if $old or $new are not
+        * instances of TextContent.
+        *
+        * Subclasses may override this to provide a different rendering for the diff,
+        * perhaps taking advantage of the content's native form. This is required for all content
+        * models that are not text based.
+        *
+        * @since 1.21
+        *
+        * @param Content $old Old content
+        * @param Content $new New content
+        *
+        * @throws MWException If old or new content is not an instance of TextContent.
+        * @return bool|string
         */
-       private function initDiffEngines() {
-               global $wgExternalDiffEngine;
-               if ( $wgExternalDiffEngine == 'wikidiff' && !function_exists( 'wikidiff_do_diff' ) ) {
-                       wfProfileIn( __METHOD__ . '-php_wikidiff.so' );
-                       wfSuppressWarnings();
-                       dl( 'php_wikidiff.so' );
-                       wfRestoreWarnings();
-                       wfProfileOut( __METHOD__ . '-php_wikidiff.so' );
-               }
-               else if ( $wgExternalDiffEngine == 'wikidiff2' && !function_exists( 'wikidiff2_do_diff' ) ) {
-                       wfProfileIn( __METHOD__ . '-php_wikidiff2.so' );
-                       wfSuppressWarnings();
-                       wfDl( 'wikidiff2' );
-                       wfRestoreWarnings();
-                       wfProfileOut( __METHOD__ . '-php_wikidiff2.so' );
+       public function generateContentDiffBody( Content $old, Content $new ) {
+               if ( !( $old instanceof TextContent ) ) {
+                       throw new MWException( "Diff not implemented for " . get_class( $old ) . "; " .
+                               "override generateContentDiffBody to fix this." );
                }
+
+               if ( !( $new instanceof TextContent ) ) {
+                       throw new MWException( "Diff not implemented for " . get_class( $new ) . "; "
+                               . "override generateContentDiffBody to fix this." );
+               }
+
+               $otext = $old->serialize();
+               $ntext = $new->serialize();
+
+               return $this->generateTextDiffBody( $otext, $ntext );
        }
 
        /**
         * Generate a diff, no caching
         *
-        * @param $otext String: old text, must be already segmented
-        * @param $ntext String: new text, must be already segmented
+        * @todo move this to TextDifferenceEngine, make DifferenceEngine abstract. At some point.
+        *
+        * @param string $otext Old text, must be already segmented
+        * @param string $ntext New text, must be already segmented
+        *
+        * @return bool|string
+        */
+       public function generateTextDiffBody( $otext, $ntext ) {
+               $diff = function () use ( $otext, $ntext ) {
+                       $time = microtime( true );
+
+                       $result = $this->textDiff( $otext, $ntext );
+
+                       $time = intval( ( microtime( true ) - $time ) * 1000 );
+                       MediaWikiServices::getInstance()->getStatsdDataFactory()->timing( 'diff_time', $time );
+                       // Log requests slower than 99th percentile
+                       if ( $time > 100 && $this->mOldPage && $this->mNewPage ) {
+                               wfDebugLog( 'diff',
+                                       "$time ms diff: {$this->mOldid} -> {$this->mNewid} {$this->mNewPage}" );
+                       }
+
+                       return $result;
+               };
+
+               /**
+                * @param Status $status
+                * @throws FatalError
+                */
+               $error = function ( $status ) {
+                       throw new FatalError( $status->getWikiText() );
+               };
+
+               // Use PoolCounter if the diff looks like it can be expensive
+               if ( strlen( $otext ) + strlen( $ntext ) > 20000 ) {
+                       $work = new PoolCounterWorkViaCallback( 'diff',
+                               md5( $otext ) . md5( $ntext ),
+                               [ 'doWork' => $diff, 'error' => $error ]
+                       );
+                       return $work->execute();
+               }
+
+               return $diff();
+       }
+
+       /**
+        * Generates diff, to be wrapped internally in a logging/instrumentation
+        *
+        * @param string $otext Old text, must be already segmented
+        * @param string $ntext New text, must be already segmented
+        * @return bool|string
         */
-       function generateDiffBody( $otext, $ntext ) {
+       protected function textDiff( $otext, $ntext ) {
                global $wgExternalDiffEngine, $wgContLang;
 
                $otext = str_replace( "\r\n", "\n", $otext );
                $ntext = str_replace( "\r\n", "\n", $ntext );
 
-               $this->initDiffEngines();
+               if ( $wgExternalDiffEngine == 'wikidiff' || $wgExternalDiffEngine == 'wikidiff3' ) {
+                       wfDeprecated( "\$wgExternalDiffEngine = '{$wgExternalDiffEngine}'", '1.27' );
+                       $wgExternalDiffEngine = false;
+               } elseif ( $wgExternalDiffEngine == 'wikidiff2' ) {
+                       // Same as above, but with no deprecation warnings
+                       $wgExternalDiffEngine = false;
+               } elseif ( !is_string( $wgExternalDiffEngine ) && $wgExternalDiffEngine !== false ) {
+                       // And prevent people from shooting themselves in the foot...
+                       wfWarn( '$wgExternalDiffEngine is set to a non-string value, forcing it to false' );
+                       $wgExternalDiffEngine = false;
+               }
+
+               // Better external diff engine, the 2 may some day be dropped
+               // This one does the escaping and segmenting itself
+               if ( function_exists( 'wikidiff2_do_diff' ) && $wgExternalDiffEngine === false ) {
+                       $wikidiff2Version = phpversion( 'wikidiff2' );
+                       if (
+                               $wikidiff2Version !== false &&
+                               version_compare( $wikidiff2Version, '1.5.0', '>=' )
+                       ) {
+                               $text = wikidiff2_do_diff(
+                                       $otext,
+                                       $ntext,
+                                       2,
+                                       $this->getConfig()->get( 'WikiDiff2MovedParagraphDetectionCutoff' )
+                               );
+                       } else {
+                               // Don't pass the 4th parameter for compatibility with older versions of wikidiff2
+                               $text = wikidiff2_do_diff(
+                                       $otext,
+                                       $ntext,
+                                       2
+                               );
 
-               if ( $wgExternalDiffEngine == 'wikidiff' && function_exists( 'wikidiff_do_diff' ) ) {
-                       # For historical reasons, external diff engine expects
-                       # input text to be HTML-escaped already
-                       $otext = htmlspecialchars ( $wgContLang->segmentForDiff( $otext ) );
-                       $ntext = htmlspecialchars ( $wgContLang->segmentForDiff( $ntext ) );
-                       return $wgContLang->unsegmentForDiff( wikidiff_do_diff( $otext, $ntext, 2 ) ) .
-                       $this->debug( 'wikidiff1' );
-               }
+                               // Log a warning in case the configuration value is set to not silently ignore it
+                               if ( $this->getConfig()->get( 'WikiDiff2MovedParagraphDetectionCutoff' ) > 0 ) {
+                                       wfLogWarning( '$wgWikiDiff2MovedParagraphDetectionCutoff is set but has no
+                                               effect since the used version of WikiDiff2 does not support it.' );
+                               }
+                       }
 
-               if ( $wgExternalDiffEngine == 'wikidiff2' && function_exists( 'wikidiff2_do_diff' ) ) {
-                       # Better external diff engine, the 2 may some day be dropped
-                       # This one does the escaping and segmenting itself
-                       wfProfileIn( 'wikidiff2_do_diff' );
-                       $text = wikidiff2_do_diff( $otext, $ntext, 2 );
                        $text .= $this->debug( 'wikidiff2' );
-                       wfProfileOut( 'wikidiff2_do_diff' );
+
                        return $text;
-               }
-               if ( $wgExternalDiffEngine != 'wikidiff3' && $wgExternalDiffEngine !== false ) {
+               } elseif ( $wgExternalDiffEngine !== false && is_executable( $wgExternalDiffEngine ) ) {
                        # Diff via the shell
-                       global $wgTmpDirectory;
-                       $tempName1 = tempnam( $wgTmpDirectory, 'diff_' );
-                       $tempName2 = tempnam( $wgTmpDirectory, 'diff_' );
+                       $tmpDir = wfTempDir();
+                       $tempName1 = tempnam( $tmpDir, 'diff_' );
+                       $tempName2 = tempnam( $tmpDir, 'diff_' );
 
                        $tempFile1 = fopen( $tempName1, "w" );
                        if ( !$tempFile1 ) {
-                               wfProfileOut( __METHOD__ );
                                return false;
                        }
                        $tempFile2 = fopen( $tempName2, "w" );
                        if ( !$tempFile2 ) {
-                               wfProfileOut( __METHOD__ );
                                return false;
                        }
                        fwrite( $tempFile1, $otext );
@@ -758,13 +967,11 @@ CONTROL;
                        fclose( $tempFile1 );
                        fclose( $tempFile2 );
                        $cmd = wfEscapeShellArg( $wgExternalDiffEngine, $tempName1, $tempName2 );
-                       wfProfileIn( __METHOD__ . "-shellexec" );
                        $difftext = wfShellExec( $cmd );
                        $difftext .= $this->debug( "external $wgExternalDiffEngine" );
-                       wfProfileOut( __METHOD__ . "-shellexec" );
                        unlink( $tempName1 );
                        unlink( $tempName2 );
-                       wfProfileOut( __METHOD__ );
+
                        return $difftext;
                }
 
@@ -773,54 +980,64 @@ CONTROL;
                $nta = explode( "\n", $wgContLang->segmentForDiff( $ntext ) );
                $diffs = new Diff( $ota, $nta );
                $formatter = new TableDiffFormatter();
-               $difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) ) .
-               wfProfileOut( __METHOD__ );
+               $difftext = $wgContLang->unsegmentForDiff( $formatter->format( $diffs ) );
+
                return $difftext;
-               $this->debug();
        }
 
        /**
         * Generate a debug comment indicating diff generating time,
         * server node, and generator backend.
+        *
+        * @param string $generator : What diff engine was used
+        *
+        * @return string
         */
        protected function debug( $generator = "internal" ) {
                global $wgShowHostnames;
                if ( !$this->enableDebugComment ) {
                        return '';
                }
-               $data = array( $generator );
+               $data = [ $generator ];
                if ( $wgShowHostnames ) {
                        $data[] = wfHostname();
                }
                $data[] = wfTimestamp( TS_DB );
+
                return "<!-- diff generator: " .
-               implode( " ",
-               array_map(
-                                       "htmlspecialchars",
-               $data ) ) .
+                       implode( " ", array_map( "htmlspecialchars", $data ) ) .
                        " -->\n";
        }
 
        /**
         * Replace line numbers with the text in the user's language
+        *
+        * @param string $text
+        *
+        * @return mixed
         */
-       function localiseLineNumbers( $text ) {
-               return preg_replace_callback( '/<!--LINE (\d+)-->/',
-               array( &$this, 'localiseLineNumbersCb' ), $text );
+       public function localiseLineNumbers( $text ) {
+               return preg_replace_callback(
+                       '/<!--LINE (\d+)-->/',
+                       [ $this, 'localiseLineNumbersCb' ],
+                       $text
+               );
        }
 
-       function localiseLineNumbersCb( $matches ) {
-               global $wgLang;
-               if ( $matches[1] === '1' && $this->mReducedLineNumbers ) return '';
-               return wfMsgExt( 'lineno', 'escape', $wgLang->formatNum( $matches[1] ) );
-       }
+       public function localiseLineNumbersCb( $matches ) {
+               if ( $matches[1] === '1' && $this->mReducedLineNumbers ) {
+                       return '';
+               }
 
+               return $this->msg( 'lineno' )->numParams( $matches[1] )->escaped();
+       }
 
        /**
         * If there are revisions between the ones being compared, return a note saying so.
+        *
         * @return string
         */
-       function getMultiNotice() {
+       public function getMultiNotice() {
                if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) {
                        return '';
                } elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) {
@@ -828,70 +1045,172 @@ CONTROL;
                        return '';
                }
 
-               $oldid = $this->mOldRev->getId();
-               $newid = $this->mNewRev->getId();
-               if ( $oldid > $newid ) {
-                       $tmp = $oldid; $oldid = $newid; $newid = $tmp;
+               if ( $this->mOldRev->getTimestamp() > $this->mNewRev->getTimestamp() ) {
+                       $oldRev = $this->mNewRev; // flip
+                       $newRev = $this->mOldRev; // flip
+               } else { // normal case
+                       $oldRev = $this->mOldRev;
+                       $newRev = $this->mNewRev;
                }
 
-               $nEdits = $this->mTitle->countRevisionsBetween( $oldid, $newid );
-               if ( $nEdits > 0 ) {
-                       $limit = 100;
-                       // We use ($limit + 1) so we can detect if there are > 100 authors
-                       // in a given revision range. In that case, diff-multi-manyusers is used.
-                       $numUsers = $this->mTitle->countAuthorsBetween( $oldid, $newid, $limit + 1 );
+               // Sanity: don't show the notice if too many rows must be scanned
+               // @todo show some special message for that case
+               $nEdits = $this->mNewPage->countRevisionsBetween( $oldRev, $newRev, 1000 );
+               if ( $nEdits > 0 && $nEdits <= 1000 ) {
+                       $limit = 100; // use diff-multi-manyusers if too many users
+                       $users = $this->mNewPage->getAuthorsBetween( $oldRev, $newRev, $limit );
+                       $numUsers = count( $users );
+
+                       if ( $numUsers == 1 && $users[0] == $newRev->getUserText( Revision::RAW ) ) {
+                               $numUsers = 0; // special case to say "by the same user" instead of "by one other user"
+                       }
+
                        return self::intermediateEditsMsg( $nEdits, $numUsers, $limit );
                }
+
                return ''; // nothing
        }
 
        /**
         * Get a notice about how many intermediate edits and users there are
-        * @param $numEdits int
-        * @param $numUsers int
-        * @param $limit int
+        *
+        * @param int $numEdits
+        * @param int $numUsers
+        * @param int $limit
+        *
         * @return string
         */
        public static function intermediateEditsMsg( $numEdits, $numUsers, $limit ) {
-               global $wgLang;
-               if ( $numUsers > $limit ) {
+               if ( $numUsers === 0 ) {
+                       $msg = 'diff-multi-sameuser';
+               } elseif ( $numUsers > $limit ) {
                        $msg = 'diff-multi-manyusers';
                        $numUsers = $limit;
                } else {
-                       $msg = 'diff-multi';
+                       $msg = 'diff-multi-otherusers';
+               }
+
+               return wfMessage( $msg )->numParams( $numEdits, $numUsers )->parse();
+       }
+
+       /**
+        * Get a header for a specified revision.
+        *
+        * @param Revision $rev
+        * @param string $complete 'complete' to get the header wrapped depending
+        *        the visibility of the revision and a link to edit the page.
+        *
+        * @return string HTML fragment
+        */
+       public function getRevisionHeader( Revision $rev, $complete = '' ) {
+               $lang = $this->getLanguage();
+               $user = $this->getUser();
+               $revtimestamp = $rev->getTimestamp();
+               $timestamp = $lang->userTimeAndDate( $revtimestamp, $user );
+               $dateofrev = $lang->userDate( $revtimestamp, $user );
+               $timeofrev = $lang->userTime( $revtimestamp, $user );
+
+               $header = $this->msg(
+                       $rev->isCurrent() ? 'currentrev-asof' : 'revisionasof',
+                       $timestamp,
+                       $dateofrev,
+                       $timeofrev
+               )->escaped();
+
+               if ( $complete !== 'complete' ) {
+                       return $header;
+               }
+
+               $title = $rev->getTitle();
+
+               $header = Linker::linkKnown( $title, $header, [],
+                       [ 'oldid' => $rev->getId() ] );
+
+               if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
+                       $editQuery = [ 'action' => 'edit' ];
+                       if ( !$rev->isCurrent() ) {
+                               $editQuery['oldid'] = $rev->getId();
+                       }
+
+                       $key = $title->quickUserCan( 'edit', $user ) ? 'editold' : 'viewsourceold';
+                       $msg = $this->msg( $key )->escaped();
+                       $editLink = $this->msg( 'parentheses' )->rawParams(
+                               Linker::linkKnown( $title, $msg, [], $editQuery ) )->escaped();
+                       $header .= ' ' . Html::rawElement(
+                               'span',
+                               [ 'class' => 'mw-diff-edit' ],
+                               $editLink
+                       );
+                       if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+                               $header = Html::rawElement(
+                                       'span',
+                                       [ 'class' => 'history-deleted' ],
+                                       $header
+                               );
+                       }
+               } else {
+                       $header = Html::rawElement( 'span', [ 'class' => 'history-deleted' ], $header );
                }
-               return wfMsgExt( $msg, 'parseinline',
-                       $wgLang->formatnum( $numEdits ), $wgLang->formatnum( $numUsers ) );
+
+               return $header;
        }
 
        /**
         * Add the header to a diff body
+        *
+        * @param string $diff Diff body
+        * @param string $otitle Old revision header
+        * @param string $ntitle New revision header
+        * @param string $multi Notice telling user that there are intermediate
+        *   revisions between the ones being compared
+        * @param string $notice Other notices, e.g. that user is viewing deleted content
+        *
+        * @return string
         */
-       static function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
-               $header = "<table class='diff'>";
-               if ( $diff ) { // Safari/Chrome show broken output if cols not used
+       public function addHeader( $diff, $otitle, $ntitle, $multi = '', $notice = '' ) {
+               // shared.css sets diff in interface language/dir, but the actual content
+               // is often in a different language, mostly the page content language/dir
+               $header = Html::openElement( 'table', [
+                       'class' => [ 'diff', 'diff-contentalign-' . $this->getDiffLang()->alignStart() ],
+                       'data-mw' => 'interface',
+               ] );
+               $userLang = htmlspecialchars( $this->getLanguage()->getHtmlCode() );
+
+               if ( !$diff && !$otitle ) {
                        $header .= "
-                       <col class='diff-marker' />
-                       <col class='diff-content' />
-                       <col class='diff-marker' />
-                       <col class='diff-content' />";
-                       $colspan = 2;
-                       $multiColspan = 4;
+                       <tr style=\"vertical-align: top;\" lang=\"{$userLang}\">
+                       <td class=\"diff-ntitle\">{$ntitle}</td>
+                       </tr>";
+                       $multiColspan = 1;
                } else {
-                       $colspan = 1;
-                       $multiColspan = 2;
+                       if ( $diff ) { // Safari/Chrome show broken output if cols not used
+                               $header .= "
+                               <col class=\"diff-marker\" />
+                               <col class=\"diff-content\" />
+                               <col class=\"diff-marker\" />
+                               <col class=\"diff-content\" />";
+                               $colspan = 2;
+                               $multiColspan = 4;
+                       } else {
+                               $colspan = 1;
+                               $multiColspan = 2;
+                       }
+                       if ( $otitle || $ntitle ) {
+                               $header .= "
+                               <tr style=\"vertical-align: top;\" lang=\"{$userLang}\">
+                               <td colspan=\"$colspan\" class=\"diff-otitle\">{$otitle}</td>
+                               <td colspan=\"$colspan\" class=\"diff-ntitle\">{$ntitle}</td>
+                               </tr>";
+                       }
                }
-               $header .= "
-               <tr valign='top'>
-               <td colspan='$colspan' class='diff-otitle'>{$otitle}</td>
-               <td colspan='$colspan' class='diff-ntitle'>{$ntitle}</td>
-               </tr>";
 
                if ( $multi != '' ) {
-                       $header .= "<tr><td colspan='{$multiColspan}' align='center' class='diff-multi'>{$multi}</td></tr>";
+                       $header .= "<tr><td colspan=\"{$multiColspan}\" style=\"text-align: center;\" " .
+                               "class=\"diff-multi\" lang=\"{$userLang}\">{$multi}</td></tr>";
                }
                if ( $notice != '' ) {
-                       $header .= "<tr><td colspan='{$multiColspan}' align='center'>{$notice}</td></tr>";
+                       $header .= "<tr><td colspan=\"{$multiColspan}\" style=\"text-align: center;\" " .
+                               "lang=\"{$userLang}\">{$notice}</td></tr>";
                }
 
                return $header . $diff . "</table>";
@@ -899,14 +1218,82 @@ CONTROL;
 
        /**
         * Use specified text instead of loading from the database
+        * @param Content $oldContent
+        * @param Content $newContent
+        * @since 1.21
         */
-       function setText( $oldText, $newText ) {
-               $this->mOldtext = $oldText;
-               $this->mNewtext = $newText;
+       public function setContent( Content $oldContent, Content $newContent ) {
+               $this->mOldContent = $oldContent;
+               $this->mNewContent = $newContent;
+
                $this->mTextLoaded = 2;
                $this->mRevisionsLoaded = true;
        }
 
+       /**
+        * Set the language in which the diff text is written
+        * (Defaults to page content language).
+        * @param Language|string $lang
+        * @since 1.19
+        */
+       public function setTextLanguage( $lang ) {
+               $this->mDiffLang = wfGetLangObj( $lang );
+       }
+
+       /**
+        * Maps a revision pair definition as accepted by DifferenceEngine constructor
+        * to a pair of actual integers representing revision ids.
+        *
+        * @param int $old Revision id, e.g. from URL parameter 'oldid'
+        * @param int|string $new Revision id or strings 'next' or 'prev', e.g. from URL parameter 'diff'
+        *
+        * @return int[] List of two revision ids, older first, later second.
+        *     Zero signifies invalid argument passed.
+        *     false signifies that there is no previous/next revision ($old is the oldest/newest one).
+        */
+       public function mapDiffPrevNext( $old, $new ) {
+               if ( $new === 'prev' ) {
+                       // Show diff between revision $old and the previous one. Get previous one from DB.
+                       $newid = intval( $old );
+                       $oldid = $this->getTitle()->getPreviousRevisionID( $newid );
+               } elseif ( $new === 'next' ) {
+                       // Show diff between revision $old and the next one. Get next one from DB.
+                       $oldid = intval( $old );
+                       $newid = $this->getTitle()->getNextRevisionID( $oldid );
+               } else {
+                       $oldid = intval( $old );
+                       $newid = intval( $new );
+               }
+
+               return [ $oldid, $newid ];
+       }
+
+       /**
+        * Load revision IDs
+        */
+       private function loadRevisionIds() {
+               if ( $this->mRevisionsIdsLoaded ) {
+                       return;
+               }
+
+               $this->mRevisionsIdsLoaded = true;
+
+               $old = $this->mOldid;
+               $new = $this->mNewid;
+
+               list( $this->mOldid, $this->mNewid ) = self::mapDiffPrevNext( $old, $new );
+               if ( $new === 'next' && $this->mNewid === false ) {
+                       # if no result, NewId points to the newest old revision. The only newer
+                       # revision is cur, which is "0".
+                       $this->mNewid = 0;
+               }
+
+               Hooks::run(
+                       'NewDifferenceEngine',
+                       [ $this->getTitle(), &$this->mOldid, &$this->mNewid, $old, $new ]
+               );
+       }
+
        /**
         * Load revision metadata for the specified articles. If newid is 0, then compare
         * the old article in oldid to the current article; if oldid is 0, then
@@ -916,73 +1303,37 @@ CONTROL;
         * If oldid is false, leave the corresponding revision object set
         * to false. This is impossible via ordinary user input, and is provided for
         * API convenience.
+        *
+        * @return bool
         */
-       function loadRevisionData() {
-               global $wgLang, $wgUser;
+       public function loadRevisionData() {
                if ( $this->mRevisionsLoaded ) {
                        return true;
-               } else {
-                       // Whether it succeeds or fails, we don't want to try again
-                       $this->mRevisionsLoaded = true;
                }
 
+               // Whether it succeeds or fails, we don't want to try again
+               $this->mRevisionsLoaded = true;
+
+               $this->loadRevisionIds();
+
                // Load the new revision object
-               $this->mNewRev = $this->mNewid
-                       ? Revision::newFromId( $this->mNewid )
-                       : Revision::newFromTitle( $this->mTitle );
-               if ( !$this->mNewRev instanceof Revision )
+               if ( $this->mNewid ) {
+                       $this->mNewRev = Revision::newFromId( $this->mNewid );
+               } else {
+                       $this->mNewRev = Revision::newFromTitle(
+                               $this->getTitle(),
+                               false,
+                               Revision::READ_NORMAL
+                       );
+               }
+
+               if ( !$this->mNewRev instanceof Revision ) {
                        return false;
+               }
 
                // Update the new revision ID in case it was 0 (makes life easier doing UI stuff)
                $this->mNewid = $this->mNewRev->getId();
-
-               // Check if page is editable
-               $editable = $this->mNewRev->getTitle()->userCan( 'edit' );
-
-               // Set assorted variables
-               $timestamp = $wgLang->timeanddate( $this->mNewRev->getTimestamp(), true );
-               $dateofrev = $wgLang->date( $this->mNewRev->getTimestamp(), true );
-               $timeofrev = $wgLang->time( $this->mNewRev->getTimestamp(), true );
                $this->mNewPage = $this->mNewRev->getTitle();
-               if ( $this->mNewRev->isCurrent() ) {
-                       $newLink = $this->mNewPage->escapeLocalUrl( array(
-                               'oldid' => $this->mNewid
-                       ) );
-                       $this->mPagetitle = htmlspecialchars( wfMsg(
-                               'currentrev-asof',
-                               $timestamp,
-                               $dateofrev,
-                               $timeofrev
-                       ) );
-                       $newEdit = $this->mNewPage->escapeLocalUrl( array(
-                               'action' => 'edit'
-                       ) );
-
-                       $this->mNewtitle = "<a href='$newLink'>{$this->mPagetitle}</a>";
-                       $this->mNewtitle .= " (<a href='$newEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
-               } else {
-                       $newLink = $this->mNewPage->escapeLocalUrl( array(
-                               'oldid' => $this->mNewid
-                       ) );
-                       $newEdit = $this->mNewPage->escapeLocalUrl( array(
-                               'action' => 'edit',
-                               'oldid' => $this->mNewid
-                       ) );
-                       $this->mPagetitle = htmlspecialchars( wfMsg(
-                               'revisionasof',
-                               $timestamp,
-                               $dateofrev,
-                               $timeofrev
-                       ) );
-
-                       $this->mNewtitle = "<a href='$newLink'>{$this->mPagetitle}</a>";
-                       $this->mNewtitle .= " (<a href='$newEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
-               }
-               if ( !$this->mNewRev->userCan( Revision::DELETED_TEXT ) ) {
-                       $this->mNewtitle = "<span class='history-deleted'>{$this->mPagetitle}</span>";
-               } else if ( $this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
-                       $this->mNewtitle = "<span class='history-deleted'>{$this->mNewtitle}</span>";
-               }
 
                // Load the old revision object
                $this->mOldRev = false;
@@ -1006,85 +1357,86 @@ CONTROL;
 
                if ( $this->mOldRev ) {
                        $this->mOldPage = $this->mOldRev->getTitle();
+               }
 
-                       $t = $wgLang->timeanddate( $this->mOldRev->getTimestamp(), true );
-                       $dateofrev = $wgLang->date( $this->mOldRev->getTimestamp(), true );
-                       $timeofrev = $wgLang->time( $this->mOldRev->getTimestamp(), true );
-                       $oldLink = $this->mOldPage->escapeLocalUrl( array(
-                               'oldid' => $this->mOldid
-                       ) );
-                       $oldEdit = $this->mOldPage->escapeLocalUrl( array(
-                               'action' => 'edit',
-                               'oldid' => $this->mOldid
-                       ) );
-                       $this->mOldPagetitle = htmlspecialchars( wfMsg( 'revisionasof', $t, $dateofrev, $timeofrev ) );
-
-                       $this->mOldtitle = "<a href='$oldLink'>{$this->mOldPagetitle}</a>"
-                       . " (<a href='$oldEdit'>" . wfMsgHtml( $editable ? 'editold' : 'viewsourceold' ) . "</a>)";
-                       // Add an "undo" link
-                       $newUndo = $this->mNewPage->escapeLocalUrl( array(
-                               'action' => 'edit',
-                               'undoafter' => $this->mOldid,
-                               'undo' => $this->mNewid
-                       ) );
-                       $htmlLink = htmlspecialchars( wfMsg( 'editundo' ) );
-                       $htmlTitle = Xml::expandAttributes( array( 'title' => $wgUser->getSkin()->titleAttrib( 'undo' ) ) );
-                       if ( $editable && !$this->mOldRev->isDeleted( Revision::DELETED_TEXT ) && !$this->mNewRev->isDeleted( Revision::DELETED_TEXT ) ) {
-                               $this->mNewtitle .= " (<a href='$newUndo' $htmlTitle>" . $htmlLink . "</a>)";
-                       }
-
-                       if ( !$this->mOldRev->userCan( Revision::DELETED_TEXT ) ) {
-                               $this->mOldtitle = '<span class="history-deleted">' . $this->mOldPagetitle . '</span>';
-                       } else if ( $this->mOldRev->isDeleted( Revision::DELETED_TEXT ) ) {
-                               $this->mOldtitle = '<span class="history-deleted">' . $this->mOldtitle . '</span>';
-                       }
+               // Load tags information for both revisions
+               $dbr = wfGetDB( DB_REPLICA );
+               if ( $this->mOldid !== false ) {
+                       $this->mOldTags = $dbr->selectField(
+                               'tag_summary',
+                               'ts_tags',
+                               [ 'ts_rev_id' => $this->mOldid ],
+                               __METHOD__
+                       );
+               } else {
+                       $this->mOldTags = false;
                }
+               $this->mNewTags = $dbr->selectField(
+                       'tag_summary',
+                       'ts_tags',
+                       [ 'ts_rev_id' => $this->mNewid ],
+                       __METHOD__
+               );
 
                return true;
        }
 
        /**
         * Load the text of the revisions, as well as revision data.
+        *
+        * @return bool
         */
-       function loadText() {
+       public function loadText() {
                if ( $this->mTextLoaded == 2 ) {
                        return true;
-               } else {
-                       // Whether it succeeds or fails, we don't want to try again
-                       $this->mTextLoaded = 2;
                }
 
+               // Whether it succeeds or fails, we don't want to try again
+               $this->mTextLoaded = 2;
+
                if ( !$this->loadRevisionData() ) {
                        return false;
                }
+
                if ( $this->mOldRev ) {
-                       $this->mOldtext = $this->mOldRev->getText( Revision::FOR_THIS_USER );
-                       if ( $this->mOldtext === false ) {
+                       $this->mOldContent = $this->mOldRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+                       if ( $this->mOldContent === null ) {
                                return false;
                        }
                }
+
                if ( $this->mNewRev ) {
-                       $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER );
-                       if ( $this->mNewtext === false ) {
+                       $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+                       Hooks::run( 'DifferenceEngineLoadTextAfterNewContentIsLoaded', [ $this ] );
+                       if ( $this->mNewContent === null ) {
                                return false;
                        }
                }
+
                return true;
        }
 
        /**
         * Load the text of the new revision, not the old one
+        *
+        * @return bool
         */
-       function loadNewText() {
+       public function loadNewText() {
                if ( $this->mTextLoaded >= 1 ) {
                        return true;
-               } else {
-                       $this->mTextLoaded = 1;
                }
+
+               $this->mTextLoaded = 1;
+
                if ( !$this->loadRevisionData() ) {
                        return false;
                }
-               $this->mNewtext = $this->mNewRev->getText( Revision::FOR_THIS_USER );
+
+               $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
+
+               Hooks::run( 'DifferenceEngineAfterLoadNewText', [ $this ] );
+
                return true;
        }
+
 }