X-Git-Url: https://git.heureux-cyclage.org/?a=blobdiff_plain;ds=sidebyside;f=includes%2Fdiff%2FDifferenceEngine.php;h=387e9e3c18964d3d0f241798240778b8e7660b33;hb=01cdb1762c7207bd261ad03726a88cb9afc97bfb;hp=513865576372931e769f2e37c79511fe61ac2a1a;hpb=bc6457b28d70641d366a554d8a82385b53a46fc9;p=lhc%2Fweb%2Fwiklou.git diff --git a/includes/diff/DifferenceEngine.php b/includes/diff/DifferenceEngine.php index 5138655763..387e9e3c18 100644 --- a/includes/diff/DifferenceEngine.php +++ b/includes/diff/DifferenceEngine.php @@ -20,12 +20,30 @@ * @file * @ingroup DifferenceEngine */ -use MediaWiki\MediaWikiServices; -use MediaWiki\Shell\Shell; + +use MediaWiki\Storage\RevisionRecord; /** - * @todo document + * DifferenceEngine is responsible for rendering the difference between two revisions as HTML. + * This includes interpreting URL parameters, retrieving revision data, checking access permissions, + * selecting and invoking the diff generator class for the individual slots, doing post-processing + * on the generated diff, adding the rest of the HTML (such as headers) and writing the whole thing + * to OutputPage. + * + * DifferenceEngine can be subclassed by extensions, by customizing + * ContentHandler::createDifferenceEngine; the content handler will be selected based on the + * content model of the main slot (of the new revision, when the two are different). + * That might change after PageTypeHandler gets introduced. + * + * In the past, the class was also used for slot-level diff generation, and extensions might still + * subclass it and add such functionality. When that is the case (sepcifically, when a + * ContentHandler returns a standard SlotDiffRenderer but a nonstandard DifferenceEngine) + * DifferenceEngineSlotDiffRenderer will be used to convert the old behavior into the new one. + * * @ingroup DifferenceEngine + * + * @todo This class is huge and poorly defined. It should be split into a controller responsible + * for interpreting query parameters, retrieving data and checking permissions; and a HTML renderer. */ class DifferenceEngine extends ContextSource { @@ -39,35 +57,88 @@ class DifferenceEngine extends ContextSource { */ const DIFF_VERSION = '1.12'; - /** @var int Revision ID or 0 for current */ + /** + * Revision ID for the old revision. 0 for the revision previous to $mNewid, false + * if the diff does not have an old revision (e.g. 'oldid=&diff=prev'), + * or the revision does not exist, null if the revision is unsaved. + * @var int|false|null + */ protected $mOldid; - /** @var int|string Revision ID or null for current or an alias such as 'next' */ + /** + * Revision ID for the new revision. 0 for the last revision of the current page + * (as defined by the request context), false if the revision does not exist, null + * if it is unsaved, or an alias such as 'next'. + * @var int|string|false|null + */ protected $mNewid; - private $mOldTags; - private $mNewTags; - - /** @var Content|null */ - protected $mOldContent; - - /** @var Content|null */ - protected $mNewContent; + /** + * Old revision (left pane). + * Allowed to be an unsaved revision, unlikely that's ever needed though. + * False when the old revision does not exist; this can happen when using + * diff=prev on the first revision. Null when the revision should exist but + * doesn't (e.g. load failure); loadRevisionData() will return false in that + * case. Also null until lazy-loaded. Ignored completely when isContentOverridden + * is set. + * Since 1.32 public access is deprecated. + * @var Revision|null|false + */ + protected $mOldRev; - /** @var Language */ - protected $mDiffLang; + /** + * New revision (right pane). + * Note that this might be an unsaved revision (e.g. for edit preview). + * Null in case of load failure; diff methods will just return an error message in that case, + * and loadRevisionData() will return false. Also null until lazy-loaded. Ignored completely + * when isContentOverridden is set. + * Since 1.32 public access is deprecated. + * @var Revision|null + */ + protected $mNewRev; - /** @var Title */ + /** + * Title of $mOldRev or null if the old revision does not exist or does not belong to a page. + * Since 1.32 public access is deprecated and the property can be null. + * @var Title|null + */ protected $mOldPage; - /** @var Title */ + /** + * Title of $mNewRev or null if the new revision does not exist or does not belong to a page. + * Since 1.32 public access is deprecated and the property can be null. + * @var Title|null + */ protected $mNewPage; - /** @var Revision|null */ - public $mOldRev; + /** + * Change tags of $mOldRev or null if it does not exist / is not saved. + * @var string[]|null + */ + private $mOldTags; + + /** + * Change tags of $mNewRev or null if it does not exist / is not saved. + * @var string[]|null + */ + private $mNewTags; + + /** + * @var Content|null + * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer. + * This property is set to the content of the main slot, but not actually used for the main diff. + */ + private $mOldContent; + + /** + * @var Content|null + * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer. + * This property is set to the content of the main slot, but not actually used for the main diff. + */ + private $mNewContent; - /** @var Revision|null */ - public $mNewRev; + /** @var Language */ + protected $mDiffLang; /** @var bool Have the revisions IDs been loaded */ private $mRevisionsIdsLoaded = false; @@ -80,7 +151,10 @@ class DifferenceEngine extends ContextSource { /** * Was the content overridden via setContent()? - * If the content was overridden, most internal state (e.g. mOldid or mOldRev) should be ignored. + * If the content was overridden, most internal state (e.g. mOldid or mOldRev) should be ignored + * and only mOldContent and mNewContent is reliable. + * (Note that setRevisions() does not set this flag as in that case all properties are + * overriden and remain consistent with each other, so no special handling is needed.) * @var bool */ protected $isContentOverridden = false; @@ -109,6 +183,17 @@ class DifferenceEngine extends ContextSource { /** @var bool Refresh the diff cache */ protected $mRefreshCache = false; + /** @var SlotDiffRenderer[] DifferenceEngine classes for the slots, keyed by role name. */ + protected $slotDiffRenderers = null; + + /** + * Temporary hack for B/C while slot diff related methods of DifferenceEngine are being + * deprecated. When true, we are inside a DifferenceEngineSlotDiffRenderer and + * $slotDiffRenderers should not be used. + * @var bool + */ + protected $isSlotDiffRenderer = false; + /**#@-*/ /** @@ -124,6 +209,8 @@ class DifferenceEngine extends ContextSource { ) { $this->deprecatePublicProperty( 'mOldid', '1.32', __CLASS__ ); $this->deprecatePublicProperty( 'mNewid', '1.32', __CLASS__ ); + $this->deprecatePublicProperty( 'mOldRev', '1.32', __CLASS__ ); + $this->deprecatePublicProperty( 'mNewRev', '1.32', __CLASS__ ); $this->deprecatePublicProperty( 'mOldPage', '1.32', __CLASS__ ); $this->deprecatePublicProperty( 'mNewPage', '1.32', __CLASS__ ); $this->deprecatePublicProperty( 'mOldContent', '1.32', __CLASS__ ); @@ -145,6 +232,91 @@ class DifferenceEngine extends ContextSource { } /** + * @return SlotDiffRenderer[] Diff renderers for each slot, keyed by role name. + * Includes slots only present in one of the revisions. + */ + protected function getSlotDiffRenderers() { + if ( $this->isSlotDiffRenderer ) { + throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' ); + } + + if ( $this->slotDiffRenderers === null ) { + if ( !$this->loadRevisionData() ) { + return []; + } + + $slotContents = $this->getSlotContents(); + $this->slotDiffRenderers = array_map( function ( $contents ) { + /** @var $content Content */ + $content = $contents['new'] ?: $contents['old']; + return $content->getContentHandler()->getSlotDiffRenderer( $this->getContext() ); + }, $slotContents ); + } + return $this->slotDiffRenderers; + } + + /** + * Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer). + * This is used in legacy mode when the DifferenceEngine is wrapped in a + * DifferenceEngineSlotDiffRenderer. + * @internal For use by DifferenceEngineSlotDiffRenderer only. + */ + public function markAsSlotDiffRenderer() { + $this->isSlotDiffRenderer = true; + } + + /** + * Get the old and new content objects for all slots. + * This method does not do any permission checks. + * @return array [ role => [ 'old' => SlotRecord|null, 'new' => SlotRecord|null ], ... ] + */ + protected function getSlotContents() { + if ( $this->isContentOverridden ) { + return [ + 'main' => [ + 'old' => $this->mOldContent, + 'new' => $this->mNewContent, + ] + ]; + } elseif ( !$this->loadRevisionData() ) { + return []; + } + + $newSlots = $this->mNewRev->getRevisionRecord()->getSlots()->getSlots(); + if ( $this->mOldRev ) { + $oldSlots = $this->mOldRev->getRevisionRecord()->getSlots()->getSlots(); + } else { + $oldSlots = []; + } + // The order here will determine the visual order of the diff. The current logic is + // slots of the new revision first in natural order, then deleted ones. This is ad hoc + // and should not be relied on - in the future we may want the ordering to depend + // on the page type. + $roles = array_merge( array_keys( $newSlots ), array_keys( $oldSlots ) ); + + $slots = []; + foreach ( $roles as $role ) { + $slots[$role] = [ + 'old' => isset( $oldSlots[$role] ) ? $oldSlots[$role]->getContent() : null, + 'new' => isset( $newSlots[$role] ) ? $newSlots[$role]->getContent() : null, + ]; + } + // move main slot to front + if ( isset( $slots['main'] ) ) { + $slots = [ 'main' => $slots['main'] ] + $slots; + } + return $slots; + } + + public function getTitle() { + // T202454 avoid errors when there is no title + return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' ); + } + + /** + * Set reduced line numbers mode. + * When set, line X is not displayed when X is 1, for example to increase readability and + * conserve space with many small diffs. * @param bool $value */ public function setReducedLineNumbers( $value = true ) { @@ -173,7 +345,11 @@ class DifferenceEngine extends ContextSource { } /** - * @return int + * Get the ID of old revision (left pane) of the diff. 0 for the revision + * previous to getNewid(), false if the old revision does not exist, null + * if it's unsaved. + * To get a real revision ID instead of 0, call loadRevisionData() first. + * @return int|false|null */ public function getOldid() { $this->loadRevisionIds(); @@ -182,7 +358,10 @@ class DifferenceEngine extends ContextSource { } /** - * @return bool|int + * Get the ID of new revision (right pane) of the diff. 0 for the current revision, + * false if the new revision does not exist, null if it's unsaved. + * To get a real revision ID instead of 0, call loadRevisionData() first. + * @return int|false|null */ public function getNewid() { $this->loadRevisionIds(); @@ -190,6 +369,25 @@ class DifferenceEngine extends ContextSource { return $this->mNewid; } + /** + * Get the left side of the diff. + * Could be null when the first revision of the page is diffed to 'prev' (or in the case of + * load failure). + * @return RevisionRecord|null + */ + public function getOldRevision() { + return $this->mOldRev ? $this->mOldRev->getRevisionRecord() : null; + } + + /** + * Get the right side of the diff. + * Should not be null but can still happen in the case of load failure. + * @return RevisionRecord|null + */ + public function getNewRevision() { + return $this->mNewRev ? $this->mNewRev->getRevisionRecord() : null; + } + /** * Look up a special:Undelete link to the given deleted revision id, * as a workaround for being unable to load deleted diffs in currently. @@ -280,8 +478,11 @@ class DifferenceEngine extends ContextSource { } $user = $this->getUser(); - $permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user ); - if ( $this->mOldPage ) { # mOldPage might not be set, see below. + $permErrors = []; + if ( $this->mNewPage ) { + $permErrors = $this->mNewPage->getUserPermissionsErrors( 'read', $user ); + } + if ( $this->mOldPage ) { $permErrors = wfMergeErrorArrays( $permErrors, $this->mOldPage->getUserPermissionsErrors( 'read', $user ) ); } @@ -311,7 +512,9 @@ class DifferenceEngine extends ContextSource { # 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() ) ); + if ( $this->mNewPage ) { + $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) ); + } $samePage = true; $oldHeader = ''; // Allow extensions to change the $oldHeader variable @@ -319,7 +522,10 @@ class DifferenceEngine extends ContextSource { } else { Hooks::run( 'DiffViewHeader', [ $this, $this->mOldRev, $this->mNewRev ] ); - if ( $this->mNewPage->equals( $this->mOldPage ) ) { + if ( !$this->mOldPage || !$this->mNewPage ) { + // XXX say something to the user? + $samePage = false; + } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) { $out->setPageTitle( $this->msg( 'difference-title', $this->mNewPage->getPrefixedText() ) ); $samePage = true; } else { @@ -329,7 +535,7 @@ class DifferenceEngine extends ContextSource { $samePage = false; } - if ( $samePage && $this->mNewPage->quickUserCan( 'edit', $user ) ) { + if ( $samePage && $this->mNewPage && $this->mNewPage->quickUserCan( 'edit', $user ) ) { if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) { $rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() ); if ( $rollbackLink ) { @@ -356,7 +562,7 @@ class DifferenceEngine extends ContextSource { } # Make "previous revision link" - if ( $samePage && $this->mOldRev->getPrevious() ) { + if ( $samePage && $this->mOldPage && $this->mOldRev->getPrevious() ) { $prevlink = Linker::linkKnown( $this->mOldPage, $this->msg( 'previousdiff' )->escaped(), @@ -409,7 +615,7 @@ class DifferenceEngine extends ContextSource { # Make "next revision link" # Skip next link on the top revision - if ( $samePage && !$this->mNewRev->isCurrent() ) { + if ( $samePage && $this->mNewPage && !$this->mNewRev->isCurrent() ) { $nextlink = Linker::linkKnown( $this->mNewPage, $this->msg( 'nextdiff' )->escaped(), @@ -517,7 +723,7 @@ class DifferenceEngine extends ContextSource { if ( $this->mMarkPatrolledLink === null ) { $linkInfo = $this->getMarkPatrolledLinkInfo(); // If false, there is no patrol link needed/allowed - if ( !$linkInfo ) { + if ( !$linkInfo || !$this->mNewPage ) { $this->mMarkPatrolledLink = ''; } else { $this->mMarkPatrolledLink = ' [' . @@ -553,7 +759,7 @@ class DifferenceEngine extends ContextSource { // Prepare a change patrol link, if applicable if ( // Is patrolling enabled and the user allowed to? - $wgUseRCPatrol && $this->mNewPage->quickUserCan( 'patrol', $user ) && + $wgUseRCPatrol && $this->mNewPage && $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 ) @@ -616,8 +822,20 @@ class DifferenceEngine extends ContextSource { /** * Show the new revision of the page. + * + * @note Not supported after calling setContent(). */ public function renderNewRevision() { + if ( $this->isContentOverridden ) { + // The code below only works with a Revision object. We could construct a fake revision + // (here or in setContent), but since this does not seem needed at the moment, + // we'll just fail for now. + throw new LogicException( + __METHOD__ + . ' is not supported after calling setContent(). Use setRevisions() instead.' + ); + } + $out = $this->getOutput(); $revHeader = $this->getRevisionHeader( $this->mNewRev ); # Add "current version as of X" title @@ -626,14 +844,26 @@ class DifferenceEngine extends ContextSource { # Page content may be handled by a hooked call instead... if ( Hooks::run( 'ArticleContentOnDiff', [ $this, $out ] ) ) { $this->loadNewText(); + if ( !$this->mNewPage ) { + // New revision is unsaved; bail out. + // TODO in theory rendering the new revision is a meaningful thing to do + // even if it's unsaved, but a lot of untangling is required to do it safely. + } + $out->setRevisionId( $this->mNewid ); $out->setRevisionTimestamp( $this->mNewRev->getTimestamp() ); $out->setArticleFlag( true ); - if ( !Hooks::run( 'ArticleContentViewCustom', - [ $this->mNewContent, $this->mNewPage, $out ] ) + if ( !Hooks::run( 'ArticleRevisionViewCustom', + [ $this->mNewRev->getRevisionRecord(), $this->mNewPage, $out ] ) + ) { + // Handled by extension + // NOTE: sync with hooks called in Article::view() + } elseif ( !Hooks::run( 'ArticleContentViewCustom', + [ $this->mNewContent, $this->mNewPage, $out ], '1.32' ) ) { // Handled by extension + // NOTE: sync with hooks called in Article::view() } else { // Normal page if ( $this->getTitle()->equals( $this->mNewPage ) ) { @@ -677,6 +907,13 @@ class DifferenceEngine extends ContextSource { * @return ParserOutput|bool False if the revision was not found */ protected function getParserOutput( WikiPage $page, Revision $rev ) { + if ( !$rev->getId() ) { + // WikiPage::getParserOutput wants a revision ID. Passing 0 will incorrectly show + // the current revision, so fail instead. If need be, WikiPage::getParserOutput + // could be made to accept a Revision or RevisionRecord instead of the id. + return false; + } + $parserOptions = $page->makeParserOptions( $this->getContext() ); $parserOutput = $page->getParserOutput( $parserOptions, $rev->getId() ); @@ -714,7 +951,12 @@ class DifferenceEngine extends ContextSource { * Add style sheets for diff display. */ public function showDiffStyle() { - $this->getOutput()->addModuleStyles( 'mediawiki.diff.styles' ); + if ( !$this->isSlotDiffRenderer ) { + $this->getOutput()->addModuleStyles( 'mediawiki.diff.styles' ); + foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) { + $slotDiffRenderer->addModules( $this->getOutput() ); + } + } } /** @@ -751,25 +993,28 @@ class DifferenceEngine extends ContextSource { public function getDiffBody() { $this->mCacheHit = true; // Check if the diff should be hidden from this user - if ( !$this->loadRevisionData() ) { - return false; - } elseif ( $this->mOldRev && - !$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) - ) { - return false; - } elseif ( $this->mNewRev && - !$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) - ) { - return false; - } - // Short-circuit - if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev - && $this->mOldRev->getId() == $this->mNewRev->getId() ) - ) { - if ( Hooks::run( 'DifferenceEngineShowEmptyOldContent', [ $this ] ) ) { - return ''; + if ( !$this->isContentOverridden ) { + if ( !$this->loadRevisionData() ) { + return false; + } elseif ( $this->mOldRev && + !$this->mOldRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) + ) { + return false; + } elseif ( $this->mNewRev && + !$this->mNewRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) + ) { + return false; + } + // Short-circuit + if ( $this->mOldRev === false || ( $this->mOldRev && $this->mNewRev && + $this->mOldRev->getId() && $this->mOldRev->getId() == $this->mNewRev->getId() ) + ) { + if ( Hooks::run( 'DifferenceEngineShowEmptyOldContent', [ $this ] ) ) { + return ''; + } } } + // Cacheable? $key = false; $cache = ObjectCache::getMainWANInstance(); @@ -800,7 +1045,20 @@ class DifferenceEngine extends ContextSource { return false; } - $difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent ); + $difftext = ''; + // We've checked for revdelete at the beginning of this method; it's OK to ignore + // read permissions here. + $slotContents = $this->getSlotContents(); + foreach ( $this->getSlotDiffRenderers() as $role => $slotDiffRenderer ) { + $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'], + $slotContents[$role]['new'] ); + if ( $slotDiff && $role !== 'main' ) { + // TODO use human-readable role name at least + $slotTitle = $role; + $difftext .= $this->getSlotHeader( $slotTitle ); + } + $difftext .= $slotDiff; + } // Avoid PHP 7.1 warning from passing $this by reference $diffEngine = $this; @@ -822,6 +1080,49 @@ class DifferenceEngine extends ContextSource { return $difftext; } + /** + * Get the diff table body for one slot, without header + * + * @param string $role + * @return string|false + */ + public function getDiffBodyForRole( $role ) { + $diffRenderers = $this->getSlotDiffRenderers(); + if ( !isset( $diffRenderers[$role] ) ) { + return false; + } + + $slotContents = $this->getSlotContents(); + $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'], + $slotContents[$role]['new'] ); + if ( !$slotDiff ) { + return false; + } + + if ( $role !== 'main' ) { + // TODO use human-readable role name at least + $slotTitle = $role; + $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff; + } + + return $this->localiseDiff( $slotDiff ); + } + + /** + * Get a slot header for inclusion in a diff body (as a table row). + * + * @param string $headerText The text of the header + * @return string + * + */ + protected function getSlotHeader( $headerText ) { + // The old revision is missing on oldid=&diff=prev; only 2 columns in that case. + $columnCount = $this->mOldRev ? 4 : 2; + $userLang = $this->getLanguage()->getHtmlCode(); + return Html::rawElement( 'tr', [ 'class' => 'mw-diff-slot-header', 'lang' => $userLang ], + Html::element( 'th', [ 'colspan' => $columnCount ], $headerText ) ); + } + /** * Returns the cache key for diff body text or content. * @@ -867,98 +1168,112 @@ class DifferenceEngine extends ContextSource { $params[] = $this->getConfig()->get( 'WikiDiff2MovedParagraphDetectionCutoff' ); } + if ( !$this->isSlotDiffRenderer ) { + foreach ( $this->getSlotDiffRenderers() as $slotDiffRenderer ) { + $params = array_merge( $params, $slotDiffRenderer->getExtraCacheKeys() ); + } + } + + return $params; + } + + /** + * Implements DifferenceEngineSlotDiffRenderer::getExtraCacheKeys(). Only used when + * DifferenceEngine is wrapped in DifferenceEngineSlotDiffRenderer. + * @return array + * @internal for use by DifferenceEngineSlotDiffRenderer only + * @deprecated + */ + public function getExtraCacheKeys() { + // This method is called when the DifferenceEngine is used for a slot diff. We only care + // about special things, not the revision IDs, which are added to the cache key by the + // page-level DifferenceEngine, and which might not have a valid value for this object. + $this->mOldid = 123456789; + $this->mNewid = 987654321; + + // This will repeat a bunch of unnecessary key fields for each slot. Not nice but harmless. + $cacheString = $this->getDiffBodyCacheKey(); + if ( $cacheString ) { + return [ $cacheString ]; + } + + $params = $this->getDiffBodyCacheKeyParams(); + + // Try to get rid of the standard keys to keep the cache key human-readable: + // call the getDiffBodyCacheKeyParams implementation of the base class, and if + // the child class includes the same keys, drop them. + // Uses an obscure PHP feature where static calls to non-static methods are allowed + // as long as we are already in a non-static method of the same class, and the call context + // ($this) will be inherited. + // phpcs:ignore Squiz.Classes.SelfMemberReference.NotUsed + $standardParams = DifferenceEngine::getDiffBodyCacheKeyParams(); + if ( array_slice( $params, 0, count( $standardParams ) ) === $standardParams ) { + $params = array_slice( $params, count( $standardParams ) ); + } + return $params; } /** * 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. + * @throws Exception If old or new content is not an instance of TextContent. * @return bool|string + * + * @deprecated since 1.32, use a SlotDiffRenderer instead. */ 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 ); + $slotDiffRenderer = $new->getContentHandler()->getSlotDiffRenderer( $this->getContext() ); + if ( + $slotDiffRenderer instanceof DifferenceEngineSlotDiffRenderer + && $this->isSlotDiffRenderer + ) { + // Oops, we are just about to enter an infinite loop (the slot-level DifferenceEngine + // called a DifferenceEngineSlotDiffRenderer that wraps the same DifferenceEngine class). + // This will happen when a content model has no custom slot diff renderer, it does have + // a custom difference engine, but that does not override this method. + throw new Exception( get_class( $this ) . ': could not maintain backwards compatibility. ' + . 'Please use a SlotDiffRenderer.' ); + } + return $slotDiffRenderer->getDiff( $old, $new ) . $this->getDebugString(); } /** * Generate a diff, no caching * - * @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 * + * @throws Exception If content handling for text content is configured in a way + * that makes maintaining B/C hard. * @return bool|string + * + * @deprecated since 1.32, use a TextSlotDiffRenderer instead. */ 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(); + $slotDiffRenderer = ContentHandler::getForModelID( CONTENT_MODEL_TEXT ) + ->getSlotDiffRenderer( $this->getContext() ); + if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) { + // Someone used the GetSlotDiffRenderer hook to replace the renderer. + // This is too unlikely to happen to bother handling properly. + throw new Exception( 'The slot diff renderer for text content should be a ' + . 'TextSlotDiffRenderer subclass' ); + } + return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString(); } /** * Process $wgExternalDiffEngine and get a sane, usable engine * * @return bool|string 'wikidiff2', path to an executable, or false + * @internal For use by this class and TextSlotDiffRenderer only. */ - private function getEngine() { + public static function getEngine() { global $wgExternalDiffEngine; // We use the global here instead of Config because we write to the value, // and Config is not mutable. @@ -989,91 +1304,23 @@ class DifferenceEngine extends ContextSource { * * @param string $otext Old text, must be already segmented * @param string $ntext New text, must be already segmented + * + * @throws Exception If content handling for text content is configured in a way + * that makes maintaining B/C hard. * @return bool|string + * + * @deprecated since 1.32, use a TextSlotDiffRenderer instead. */ protected function textDiff( $otext, $ntext ) { - $otext = str_replace( "\r\n", "\n", $otext ); - $ntext = str_replace( "\r\n", "\n", $ntext ); - - $engine = $this->getEngine(); - - // Better external diff engine, the 2 may some day be dropped - // This one does the escaping and segmenting itself - if ( $engine === 'wikidiff2' ) { - $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 - ); - - // 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.' ); - } - } - - $text .= $this->debug( 'wikidiff2' ); - - return $text; - } elseif ( $engine !== false ) { - # Diff via the shell - $tmpDir = wfTempDir(); - $tempName1 = tempnam( $tmpDir, 'diff_' ); - $tempName2 = tempnam( $tmpDir, 'diff_' ); - - $tempFile1 = fopen( $tempName1, "w" ); - if ( !$tempFile1 ) { - return false; - } - $tempFile2 = fopen( $tempName2, "w" ); - if ( !$tempFile2 ) { - return false; - } - fwrite( $tempFile1, $otext ); - fwrite( $tempFile2, $ntext ); - fclose( $tempFile1 ); - fclose( $tempFile2 ); - $cmd = [ $engine, $tempName1, $tempName2 ]; - $result = Shell::command( $cmd ) - ->execute(); - $exitCode = $result->getExitCode(); - if ( $exitCode !== 0 ) { - throw new Exception( "External diff command returned code {$exitCode}. Stderr: " - . wfEscapeWikiText( $result->getStderr() ) - ); - } - $difftext = $result->getStdout(); - $difftext .= $this->debug( "external $engine" ); - unlink( $tempName1 ); - unlink( $tempName2 ); - - return $difftext; - } - - # Native PHP diff - $contLang = MediaWikiServices::getInstance()->getContentLanguage(); - $ota = explode( "\n", $contLang->segmentForDiff( $otext ) ); - $nta = explode( "\n", $contLang->segmentForDiff( $ntext ) ); - $diffs = new Diff( $ota, $nta ); - $formatter = new TableDiffFormatter(); - $difftext = $contLang->unsegmentForDiff( $formatter->format( $diffs ) ); - $difftext .= $this->debug( 'native PHP' ); - - return $difftext; + $slotDiffRenderer = ContentHandler::getForModelID( CONTENT_MODEL_TEXT ) + ->getSlotDiffRenderer( $this->getContext() ); + if ( !( $slotDiffRenderer instanceof TextSlotDiffRenderer ) ) { + // Someone used the GetSlotDiffRenderer hook to replace the renderer. + // This is too unlikely to happen to bother handling properly. + throw new Exception( 'The slot diff renderer for text content should be a ' + . 'TextSlotDiffRenderer subclass' ); + } + return $slotDiffRenderer->getTextDiff( $otext, $ntext ) . $this->getDebugString(); } /** @@ -1100,6 +1347,17 @@ class DifferenceEngine extends ContextSource { " -->\n"; } + private function getDebugString() { + $engine = self::getEngine(); + if ( $engine === 'wikidiff2' ) { + return $this->debug( 'wikidiff2' ); + } elseif ( $engine === false ) { + return $this->debug( 'native PHP' ); + } else { + return $this->debug( "external $engine" ); + } + } + /** * Localise diff output * @@ -1170,10 +1428,12 @@ class DifferenceEngine extends ContextSource { * @return string */ public function getMultiNotice() { - if ( !is_object( $this->mOldRev ) || !is_object( $this->mNewRev ) ) { - return ''; - } elseif ( !$this->mOldPage->equals( $this->mNewPage ) ) { - // Comparing two different pages? Count would be meaningless. + // The notice only make sense if we are diffing two saved revisions of the same page. + if ( + !$this->mOldRev || !$this->mNewRev + || !$this->mOldPage || !$this->mNewPage + || !$this->mOldPage->equals( $this->mNewPage ) + ) { return ''; } @@ -1353,6 +1613,7 @@ class DifferenceEngine extends ContextSource { * @param Content $oldContent * @param Content $newContent * @since 1.21 + * @deprecated since 1.32, use setRevisions or ContentHandler::getSlotDiffRenderer. */ public function setContent( Content $oldContent, Content $newContent ) { $this->mOldContent = $oldContent; @@ -1361,6 +1622,39 @@ class DifferenceEngine extends ContextSource { $this->mTextLoaded = 2; $this->mRevisionsLoaded = true; $this->isContentOverridden = true; + $this->slotDiffRenderers = null; + } + + /** + * Use specified text instead of loading from the database. + * @param RevisionRecord|null $oldRevision + * @param RevisionRecord $newRevision + */ + public function setRevisions( + RevisionRecord $oldRevision = null, RevisionRecord $newRevision + ) { + if ( $oldRevision ) { + $this->mOldRev = new Revision( $oldRevision ); + $this->mOldid = $oldRevision->getId(); + $this->mOldPage = Title::newFromLinkTarget( $oldRevision->getPageAsLinkTarget() ); + // This method is meant for edit diffs and such so there is no reason to provide a + // revision that's not readable to the user, but check it just in case. + $this->mOldContent = $oldRevision ? $oldRevision->getContent( 'main', + RevisionRecord::FOR_THIS_USER, $this->getUser() ) : null; + } else { + $this->mOldPage = null; + $this->mOldRev = $this->mOldid = false; + } + $this->mNewRev = new Revision( $newRevision ); + $this->mNewid = $newRevision->getId(); + $this->mNewPage = Title::newFromLinkTarget( $newRevision->getPageAsLinkTarget() ); + $this->mNewContent = $newRevision->getContent( 'main', + RevisionRecord::FOR_THIS_USER, $this->getUser() ); + + $this->mRevisionsIdsLoaded = $this->mRevisionsLoaded = true; + $this->mTextLoaded = !!$oldRevision + 1; + $this->isContentOverridden = false; + $this->slotDiffRenderers = null; } /** @@ -1383,7 +1677,7 @@ class DifferenceEngine extends ContextSource { * @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. + * @return array 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). */ @@ -1431,20 +1725,21 @@ class DifferenceEngine extends ContextSource { } /** - * 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 - * compare the current article to the immediately previous one (ignoring the - * value of newid). + * Load revision metadata for the specified revisions. If newid is 0, then compare + * the old revision in oldid to the current revision of the current page (as defined + * by the request context); if oldid is 0, then compare the revision in newid to the + * immediately previous one. * * 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. + * to false. This can happen with 'diff=prev' pointing to a non-existent revision, + * and is also used directly by the API. * - * @return bool Whether both revisions were loaded successfully. + * @return bool Whether both revisions were loaded successfully. Setting mOldRev + * to false counts as successful loading. */ public function loadRevisionData() { if ( $this->mRevisionsLoaded ) { - return $this->isContentOverridden || $this->mNewRev && $this->mOldRev; + return $this->isContentOverridden || $this->mNewRev && !is_null( $this->mOldRev ); } // Whether it succeeds or fails, we don't want to try again @@ -1469,7 +1764,11 @@ class DifferenceEngine extends ContextSource { // Update the new revision ID in case it was 0 (makes life easier doing UI stuff) $this->mNewid = $this->mNewRev->getId(); - $this->mNewPage = $this->mNewRev->getTitle(); + if ( $this->mNewid ) { + $this->mNewPage = $this->mNewRev->getTitle(); + } else { + $this->mNewPage = null; + } // Load the old revision object $this->mOldRev = false; @@ -1491,8 +1790,10 @@ class DifferenceEngine extends ContextSource { return false; } - if ( $this->mOldRev ) { + if ( $this->mOldRev && $this->mOldRev->getId() ) { $this->mOldPage = $this->mOldRev->getTitle(); + } else { + $this->mOldPage = null; } // Load tags information for both revisions @@ -1519,12 +1820,16 @@ class DifferenceEngine extends ContextSource { /** * Load the text of the revisions, as well as revision data. + * When the old revision is missing (mOldRev is false), loading mOldContent is not attempted. * * @return bool Whether the content of both revisions could be loaded successfully. + * (When mOldRev is false, that still counts as a success.) + * */ public function loadText() { if ( $this->mTextLoaded == 2 ) { - return $this->loadRevisionData() && $this->mOldContent && $this->mNewContent; + return $this->loadRevisionData() && ( $this->mOldRev === false || $this->mOldContent ) + && $this->mNewContent; } // Whether it succeeds or fails, we don't want to try again @@ -1541,12 +1846,10 @@ class DifferenceEngine extends ContextSource { } } - if ( $this->mNewRev ) { - $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ); - Hooks::run( 'DifferenceEngineLoadTextAfterNewContentIsLoaded', [ $this ] ); - if ( $this->mNewContent === null ) { - return false; - } + $this->mNewContent = $this->mNewRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ); + Hooks::run( 'DifferenceEngineLoadTextAfterNewContentIsLoaded', [ $this ] ); + if ( $this->mNewContent === null ) { + return false; } return true;