* @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 {
*/
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=<first revision of page>&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;
/**
* 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;
/** @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;
+
/**#@-*/
/**
) {
$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__ );
}
/**
+ * @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 ) {
}
/**
- * @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();
}
/**
- * @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();
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.
}
$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 ) );
}
# 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
} 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 {
$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 ) {
}
# 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(),
# 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(),
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 = ' <span class="patrollink" data-mw="interface">[' .
// 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 )
/**
* 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
# 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 ) ) {
* @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() );
* 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() );
+ }
+ }
}
/**
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();
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;
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=<first>&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.
*
$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.
*
* @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();
}
/**
" -->\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
*
* @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 '';
}
* @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;
$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;
}
/**
* @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).
*/
}
/**
- * 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
// 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;
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
/**
* 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
}
}
- 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;