'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php',
'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php',
'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php',
+ 'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php',
+ 'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php',
'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php',
'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\Preferences\PreferencesFactory;
use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Special\SpecialPageFactory;
use MediaWiki\Storage\BlobStore;
use MediaWiki\Storage\BlobStoreFactory;
return $this->getService( 'RevisionLookup' );
}
+ /**
+ * @since 1.32
+ * @return RevisionRenderer
+ */
+ public function getRevisionRenderer() {
+ return $this->getService( 'RevisionRenderer' );
+ }
+
/**
* @since 1.31
* @return RevisionStore
--- /dev/null
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Revision;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
+ * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
+ * of all slots.
+ *
+ * @since 1.32
+ */
+class RenderedRevision {
+
+ /**
+ * @var Title
+ */
+ private $title;
+
+ /** @var RevisionRecord */
+ private $revision;
+
+ /**
+ * @var ParserOptions
+ */
+ private $options;
+
+ /**
+ * @var int Audience to check when accessing content.
+ */
+ private $audience = RevisionRecord::FOR_PUBLIC;
+
+ /**
+ * @var User|null The user to use for audience checks during content access.
+ */
+ private $forUser = null;
+
+ /**
+ * @var ParserOutput|null The combined ParserOutput for the revision,
+ * initialized lazily by getRevisionParserOutput().
+ */
+ private $revisionOutput = null;
+
+ /**
+ * @var ParserOutput[] The ParserOutput for each slot,
+ * initialized lazily by getSlotParserOutput().
+ */
+ private $slotsOutput = [];
+
+ /**
+ * @var callable Callback for combining slot output into revision output.
+ * Signature: function ( RenderedRevision $this ): ParserOutput.
+ */
+ private $combineOutput;
+
+ /**
+ * @var LoggerInterface For profiling ParserOutput re-use.
+ */
+ private $saveParseLogger;
+
+ /**
+ * @note Application logic should not instantiate RenderedRevision instances directly,
+ * but should use a RevisionRenderer instead.
+ *
+ * @param Title $title
+ * @param RevisionRecord $revision
+ * @param ParserOptions $options
+ * @param callable $combineOutput Callback for combining slot output into revision output.
+ * Signature: function ( RenderedRevision $this ): ParserOutput.
+ * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
+ * @param User|null $forUser Required if $audience is FOR_THIS_USER.
+ */
+ public function __construct(
+ Title $title,
+ RevisionRecord $revision,
+ ParserOptions $options,
+ callable $combineOutput,
+ $audience = RevisionRecord::FOR_PUBLIC,
+ User $forUser = null
+ ) {
+ $this->title = $title;
+ $this->options = $options;
+
+ $this->setRevisionInternal( $revision );
+
+ $this->combineOutput = $combineOutput;
+ $this->saveParseLogger = new NullLogger();
+
+ if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
+ throw new InvalidArgumentException(
+ 'User must be specified when setting audience to FOR_THIS_USER'
+ );
+ }
+
+ $this->audience = $audience;
+ $this->forUser = $forUser;
+ }
+
+ /**
+ * @param LoggerInterface $saveParseLogger
+ */
+ public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
+ $this->saveParseLogger = $saveParseLogger;
+ }
+
+ /**
+ * @return bool Whether the revision's content has been hidden from unprivileged users.
+ */
+ public function isContentDeleted() {
+ return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+ }
+
+ /**
+ * @return RevisionRecord
+ */
+ public function getRevision() {
+ return $this->revision;
+ }
+
+ /**
+ * @return ParserOptions
+ */
+ public function getOptions() {
+ return $this->options;
+ }
+
+ /**
+ * @return ParserOutput
+ */
+ public function getRevisionParserOutput() {
+ if ( !$this->revisionOutput ) {
+ $output = call_user_func( $this->combineOutput, $this );
+
+ Assert::postcondition(
+ $output instanceof ParserOutput,
+ 'Callback did not return a ParserOutput object!'
+ );
+
+ $this->revisionOutput = $output;
+ }
+
+ return $this->revisionOutput;
+ }
+
+ /**
+ * @param string $role
+ *
+ * @throws SuppressedDataException if the content is not accessible for the audience
+ * specified in the constructor.
+ * @return ParserOutput
+ */
+ public function getSlotParserOutput( $role ) {
+ // XXX: make html generation optional?
+
+ if ( !isset( $this->slotsOutput[$role] ) ) {
+ $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
+
+ if ( !$content ) {
+ throw new SuppressedDataException(
+ 'Access to the content has been suppressed for this audience'
+ );
+ } else {
+ $this->slotsOutput[ $role ] = $content->getParserOutput(
+ $this->title,
+ $this->revision->getId(),
+ $this->options
+ );
+
+ // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
+ $this->options->registerWatcher( null );
+ }
+ }
+
+ return $this->slotsOutput[$role];
+ }
+
+ /**
+ * Updates the RevisionRecord after the revision has been saved. This can be used to discard
+ * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
+ * are re-evaluated.
+ *
+ * @note There should be no need to call this for null-edits.
+ *
+ * @param RevisionRecord $rev
+ */
+ public function updateRevision( RevisionRecord $rev ) {
+ if ( $rev->getId() === $this->revision->getId() ) {
+ return;
+ }
+
+ if ( $this->revision->getId() ) {
+ throw new LogicException( 'RenderedRevision already has a revision with ID '
+ . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
+ }
+
+ if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
+ throw new LogicException( 'Cannot update to a revision with different content!' );
+ }
+
+ $this->setRevisionInternal( $rev );
+
+ $this->pruneRevisionSensitiveOutput( $this->revision->getId() );
+ }
+
+ /**
+ * Prune any output that depends on the revision ID.
+ *
+ * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
+ * against, or false to not purge on vary-revision-id, or true to purge on
+ * vary-revision-id unconditionally.
+ */
+ private function pruneRevisionSensitiveOutput( $actualRevId ) {
+ if ( $this->revisionOutput ) {
+ if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) {
+ $this->revisionOutput = null;
+ }
+ } else {
+ $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" );
+ }
+
+ foreach ( $this->slotsOutput as $role => $output ) {
+ if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
+ unset( $this->slotsOutput[$role] );
+ }
+ }
+ }
+
+ /**
+ * @param RevisionRecord $revision
+ */
+ private function setRevisionInternal( RevisionRecord $revision ) {
+ $this->revision = $revision;
+
+ // Make sure the parser uses the correct Revision object
+ $title = $this->title;
+ $oldCallback = $this->options->getCurrentRevisionCallback();
+ $this->options->setCurrentRevisionCallback(
+ function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
+ if ( $parserTitle->equals( $title ) ) {
+ $legacyRevision = new Revision( $this->revision );
+ return $legacyRevision;
+ } else {
+ return call_user_func( $oldCallback, $parserTitle, $parser );
+ }
+ }
+ );
+ }
+
+ /**
+ * @param ParserOutput $out
+ * @param int|bool $actualRevId The actual rev id, to check the used speculative rev ID
+ * against, or false to not purge on vary-revision-id, or true to purge on
+ * vary-revision-id unconditionally.
+ * @return bool
+ */
+ private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) {
+ $method = __METHOD__;
+
+ if ( $out->getFlag( 'vary-revision' ) ) {
+ // XXX: Would be just keep the output if the speculative revision ID was correct,
+ // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
+ // For that specific case, it would perhaps nice to have a vary-page flag.
+ $this->saveParseLogger->info(
+ "$method: Prepared output has vary-revision...\n"
+ );
+ return true;
+ } elseif ( $out->getFlag( 'vary-revision-id' )
+ && $actualRevId !== false
+ && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
+ ) {
+ $this->saveParseLogger->info(
+ "$method: Prepared output has vary-revision-id with wrong ID...\n"
+ );
+ return true;
+ } else {
+ // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
+ // set for a null-edit. The reason was that the original rendering in that case was
+ // targeting the user making the null-edit, not the user who made the original edit,
+ // causing {{REVISIONUSER}} to return the wrong name.
+ // This case is now expected to be handled by the code in RevisionRenderer that
+ // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
+ // with the old, existing revision.
+
+ wfDebug( "$method: Keeping prepared output...\n" );
+ return false;
+ }
+ }
+
+}
--- /dev/null
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Revision;
+
+use Html;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Title;
+use User;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * The RevisionRenderer service provides access to rendered output for revisions.
+ * It does so be acting as a factory for RenderedRevision instances, which in turn
+ * provide lazy access to ParserOutput objects.
+ *
+ * One key responsibility of RevisionRenderer is implementing the layout used to combine
+ * the output of multiple slots.
+ *
+ * @since 1.32
+ */
+class RevisionRenderer {
+
+ /** @var LoggerInterface */
+ private $saveParseLogger;
+
+ /** @var ILoadBalancer */
+ private $loadBalancer;
+
+ /** @var string|bool */
+ private $wikiId;
+
+ /**
+ * @param ILoadBalancer $loadBalancer
+ * @param bool|string $wikiId
+ */
+ public function __construct( ILoadBalancer $loadBalancer, $wikiId = false ) {
+ $this->loadBalancer = $loadBalancer;
+ $this->wikiId = $wikiId;
+
+ $this->saveParseLogger = new NullLogger();
+ }
+
+ /**
+ * @param RevisionRecord $rev
+ * @param ParserOptions|null $options
+ * @param User|null $forUser User for privileged access. Default is unprivileged (public)
+ * access, unless the 'audience' hint is set to something else RevisionRecord::RAW.
+ * @param array $hints Hints given as an associative array. Known keys:
+ * - 'use-master' Use master when rendering for the parser cache during save.
+ * Default is to use a replica.
+ * - 'audience' the audience to use for content access. Default is
+ * RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER
+ * if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks.
+ *
+ * @return RenderedRevision|null The rendered revision, or null if the audience checks fails.
+ */
+ public function getRenderedRevision(
+ RevisionRecord $rev,
+ ParserOptions $options = null,
+ User $forUser = null,
+ array $hints = []
+ ) {
+ if ( $rev->getWikiId() !== $this->wikiId ) {
+ throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() );
+ }
+
+ $audience = $hints['audience']
+ ?? ( $forUser ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC );
+
+ if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forUser ) ) {
+ // Returning null here is awkward, but consist with the signature of
+ // Revision::getContent() and RevisionRecord::getContent().
+ return null;
+ }
+
+ if ( !$options ) {
+ $options = ParserOptions::newCanonical( $forUser ?: 'canonical' );
+ }
+
+ $useMaster = $hints['use-master'] ?? false;
+
+ $dbIndex = $useMaster
+ ? DB_MASTER // use latest values
+ : DB_REPLICA; // T154554
+
+ $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
+ return $this->getSpeculativeRevId( $dbIndex );
+ } );
+
+ $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
+
+ $renderedRevision = new RenderedRevision(
+ $title,
+ $rev,
+ $options,
+ function ( RenderedRevision $rrev ) {
+ return $this->combineSlotOutput( $rrev );
+ },
+ $audience,
+ $forUser
+ );
+
+ $renderedRevision->setSaveParseLogger( $this->saveParseLogger );
+
+ return $renderedRevision;
+ }
+
+ private function getSpeculativeRevId( $dbIndex ) {
+ // Use a fresh master connection in order to see the latest data, by avoiding
+ // stale data from REPEATABLE-READ snapshots.
+ // HACK: But don't use a fresh connection in unit tests, since it would not have
+ // the fake tables. This should be handled by the LoadBalancer!
+ $flags = defined( 'MW_PHPUNIT_TEST' ) || $dbIndex === DB_REPLICA
+ ? 0 : ILoadBalancer::CONN_TRX_AUTOCOMMIT;
+
+ $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->wikiId, $flags );
+
+ return 1 + (int)$db->selectField(
+ 'revision',
+ 'MAX(rev_id)',
+ [],
+ __METHOD__
+ );
+ }
+
+ /**
+ * This implements the layout for combining the output of multiple slots.
+ *
+ * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout.
+ *
+ * @param RenderedRevision $rrev
+ * @return ParserOutput
+ */
+ private function combineSlotOutput( RenderedRevision $rrev ) {
+ $revision = $rrev->getRevision();
+ $slots = $revision->getSlots()->getSlots();
+
+ // short circuit if there is only the main slot
+ if ( array_keys( $slots ) === [ 'main' ] ) {
+ return $rrev->getSlotParserOutput( 'main' );
+ }
+
+ // TODO: put fancy layout logic here, see T200915.
+
+ // move main slot to front
+ if ( isset( $slots['main'] ) ) {
+ $slots = [ 'main' => $slots['main'] ] + $slots;
+ }
+
+ $output = new ParserOutput();
+ $options = $rrev->getOptions();
+ $options->registerWatcher( [ $output, 'recordOption' ] );
+
+ $html = '';
+ $first = true;
+ foreach ( $slots as $role => $slot ) {
+
+ if ( $first ) {
+ // skip header for the first slot
+ $first = false;
+ } else {
+ // NOTE: this placeholder is hydrated by ParserOutput::getText().
+ $headText = Html::element( 'mw:slotheader', [], $role );
+ $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
+ }
+
+ $slotOutput = $rrev->getSlotParserOutput( $role );
+
+ $html .= $slotOutput->getRawText();
+
+ $output->mergeInternalMetaDataFrom( $slotOutput );
+ $output->mergeHtmlMetaDataFrom( $slotOutput );
+ $output->mergeTrackingMetaDataFrom( $slotOutput );
+ }
+
+ $output->setText( $html );
+
+ $options->registerWatcher( null );
+ return $output;
+ }
+
+}
use MediaWiki\Shell\CommandFactory;
use MediaWiki\Special\SpecialPageFactory;
use MediaWiki\Storage\BlobStore;
+use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Storage\BlobStoreFactory;
use MediaWiki\Storage\NameTableStore;
use MediaWiki\Storage\RevisionFactory;
return $services->getRevisionStore();
},
+ 'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer {
+ return new RevisionRenderer( $services->getDBLoadBalancer() );
+ },
+
'RevisionStore' => function ( MediaWikiServices $services ) : RevisionStore {
return $services->getRevisionStoreFactory()->getRevisionStore();
},
use LinksUpdate;
use LogicException;
use MediaWiki\Edit\PreparedEdit;
-use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\User\UserIdentity;
use MessageCache;
use ParserCache;
use ParserOptions;
use ParserOutput;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
use RecentChangesUpdateJob;
use ResourceLoaderWikiModule;
use Revision;
*/
private $contLang;
- /**
- * @var LoggerInterface
- */
- private $saveParseLogger;
-
/**
* @var JobQueueGroup
*/
private $slotsUpdate = null;
/**
- * @var MutableRevisionSlots|null
- */
- private $pstContentSlots = null;
-
- /**
- * @var object[] anonymous objects with two fields, using slot roles as keys:
- * - hasHtml: whether the output contains HTML
- * - ParserOutput: the slot's parser output
- */
- private $slotsOutput = [];
-
- /**
- * @var ParserOutput|null
+ * @var RevisionRecord|null
*/
- private $canonicalParserOutput = null;
+ private $revision = null;
/**
- * @var ParserOptions|null
+ * @var RenderedRevision
*/
- private $canonicalParserOptions = null;
+ private $renderedRevision = null;
/**
- * @var RevisionRecord
+ * @var RevisionRenderer
*/
- private $revision = null;
+ private $revisionRenderer;
/**
* A stage identifier for managing the life cycle of this instance.
/**
* @param WikiPage $wikiPage ,
* @param RevisionStore $revisionStore
+ * @param RevisionRenderer $revisionRenderer
* @param ParserCache $parserCache
* @param JobQueueGroup $jobQueueGroup
* @param MessageCache $messageCache
* @param Language $contLang
- * @param LoggerInterface|null $saveParseLogger
*/
public function __construct(
WikiPage $wikiPage,
RevisionStore $revisionStore,
+ RevisionRenderer $revisionRenderer,
ParserCache $parserCache,
JobQueueGroup $jobQueueGroup,
MessageCache $messageCache,
- Language $contLang,
- LoggerInterface $saveParseLogger = null
+ Language $contLang
) {
$this->wikiPage = $wikiPage;
$this->parserCache = $parserCache;
$this->revisionStore = $revisionStore;
+ $this->revisionRenderer = $revisionRenderer;
$this->jobQueueGroup = $jobQueueGroup;
$this->messageCache = $messageCache;
$this->contLang = $contLang;
-
- // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here?
- $this->saveParseLogger = $saveParseLogger ?: new NullLogger();
}
/**
return false;
}
- if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) {
+ if ( $revision && $this->revision && $this->revision->getId()
+ && $this->revision->getId() !== $revision->getId()
+ ) {
return false;
}
if ( $this->revision
&& $user
+ && $this->revision->getUser( RevisionRecord::RAW )
&& $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
) {
return false;
if ( $revision
&& $this->user
+ && $this->revision->getUser( RevisionRecord::RAW )
&& $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
) {
return false;
return false;
}
- if ( $this->pstContentSlots
- && $revision
- && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+ if ( $revision
+ && $this->revision
+ && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
) {
return false;
}
* @return bool
*/
public function isContentPrepared() {
- return $this->pstContentSlots !== null;
+ return $this->revision !== null;
}
/**
* Whether prepareUpdate() has been called on this instance.
*
+ * @note will also return null in case of a null-edit!
+ *
* @return bool
*/
public function isUpdatePrepared() {
- return $this->revision !== null;
+ return $this->revision !== null && $this->revision->getId() !== null;
}
/**
}
/**
- * Whether the content of the target revision is publicly visible.
+ * Whether the content is deleted and thus not visible to the public.
*
* @return bool
*/
- public function isContentPublic() {
+ public function isContentDeleted() {
if ( $this->revision ) {
- // XXX: if that revision is the current revision, this can be skipped
- return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+ // XXX: if that revision is the current revision, this should be skipped
+ return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
} else {
- // If the content has not been saved yet, it cannot have been suppressed yet.
- return true;
+ // If the content has not been saved yet, it cannot have been deleted yet.
+ return false;
}
}
return false;
}
- if ( !$this->isContentPublic() ) {
+ if ( $this->isContentDeleted() ) {
// This should be irrelevant: countability only applies to the current revision,
// and the current revision is never suppressed.
return false;
$this->slotsOutput = [];
$this->canonicalParserOutput = null;
- $this->canonicalParserOptions = null;
// The edit may have already been prepared via api.php?action=stashedit
$stashedEdit = false;
$this->slotsUpdate = $slotsUpdate;
if ( $parentRevision ) {
- // start out by inheriting all parent slots
- $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots(
- $parentRevision->getSlots()->getSlots()
- );
+ $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
} else {
- $this->pstContentSlots = new MutableRevisionSlots();
+ $this->revision = new MutableRevisionRecord( $title );
}
+ $pstContentSlots = $this->revision->getSlots();
+
foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
$slot = $slotsUpdate->getModifiedSlot( $role );
$pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
}
- $this->pstContentSlots->setSlot( $pstSlot );
+ $pstContentSlots->setSlot( $pstSlot );
}
foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
- $this->pstContentSlots->removeSlot( $role );
+ $pstContentSlots->removeSlot( $role );
}
$this->options['created'] = ( $parentRevision === null );
$this->options['changed'] = ( $parentRevision === null
- || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
+ || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
$this->doTransition( 'has-content' );
+
+ if ( !$this->options['changed'] ) {
+ // null-edit!
+
+ // TODO: move this into MutableRevisionRecord
+ // TODO: This needs to behave differently for a forced dummy edit!
+ $this->revision->setId( $parentRevision->getId() );
+ $this->revision->setTimestamp( $parentRevision->getTimestamp() );
+ $this->revision->setPageId( $parentRevision->getPageId() );
+ $this->revision->setParentId( $parentRevision->getParentId() );
+ $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
+ $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
+ $this->revision->setMinorEdit( $parentRevision->isMinor() );
+ $this->revision->setVisibility( $parentRevision->getVisibility() );
+
+ // prepareUpdate() is redundant for null-edits
+ $this->doTransition( 'has-revision' );
+ } else {
+ $this->revision->setUser( $user );
+ }
+ }
+
+ /**
+ * Returns the update's target revision - that is, the revision that will be the current
+ * revision after the update.
+ *
+ * @note Callers must treat the returned RevisionRecord's content as immutable, even
+ * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
+ * returned from here, such as the user or the comment, may be changed, but may not
+ * be reflected in ParserOutput until after prepareUpdate() has been called.
+ *
+ * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
+ * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
+ * for that purpose instead!
+ *
+ * @return RevisionRecord
+ */
+ public function getRevision() {
+ $this->assertPrepared( __METHOD__ );
+ return $this->revision;
+ }
+
+ /**
+ * @return RenderedRevision
+ */
+ public function getRenderedRevision() {
+ if ( !$this->renderedRevision ) {
+ $this->assertPrepared( __METHOD__ );
+
+ // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
+ // NOTE: the revision is either new or current, so we can bypass audience checks.
+ $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
+ $this->revision,
+ null,
+ null,
+ [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ]
+ );
+ }
+
+ return $this->renderedRevision;
}
private function assertHasPageState( $method ) {
}
private function assertPrepared( $method ) {
- if ( !$this->pstContentSlots ) {
+ if ( !$this->revision ) {
throw new LogicException(
'Must call prepareContent() or prepareUpdate() before calling ' . $method
);
/**
* Returns the slots of the target revision, after PST.
*
+ * @note Callers must treat the returned RevisionSlots instance as immutable, even
+ * if it is a MutableRevisionSlots instance.
+ *
* @return RevisionSlots
*/
public function getSlots() {
$this->assertPrepared( __METHOD__ );
- return $this->pstContentSlots;
+ return $this->revision->getSlots();
}
/**
$this->assertPrepared( __METHOD__ );
if ( !$this->slotsUpdate ) {
- if ( !$this->revision ) {
- // This should not be possible: if assertPrepared() returns true,
- // at least one of $this->slotsUpdate or $this->revision should be set.
- throw new LogicException( 'No revision nor a slots update is known!' );
- }
-
$old = $this->getOldRevision();
$this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
$this->revision->getSlots(),
* - moved: bool, whether the page was moved (default false)
* - restored: bool, whether the page was undeleted (default false)
* - oldrevision: Revision object for the pre-update revision (default null)
- * - parseroutput: The canonical ParserOutput of $revision (default null)
* - triggeringuser: The user triggering the update (UserIdentity, default null)
* - oldredirect: bool, null, or string 'no-change' (default null):
* - bool: whether the page was counted as a redirect before that
'$options["oldrevision"]',
'must be a RevisionRecord (or Revision)'
);
- Assert::parameter(
- !isset( $options['parseroutput'] )
- || $options['parseroutput'] instanceof ParserOutput,
- '$options["parseroutput"]',
- 'must be a ParserOutput'
- );
Assert::parameter(
!isset( $options['triggeringuser'] )
|| $options['triggeringuser'] instanceof UserIdentity,
);
}
- if ( $this->revision ) {
+ if ( $this->revision && $this->revision->getId() ) {
if ( $this->revision->getId() === $revision->getId() ) {
return; // nothing to do!
} else {
}
}
- if ( $this->pstContentSlots
- && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+ if ( $this->revision
+ && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
) {
throw new LogicException(
'The Revision provided has mismatching content!'
$this->options['created'] = ( $this->pageState['oldId'] === 0 );
$this->revision = $revision;
- $this->pstContentSlots = $revision->getSlots();
$this->doTransition( 'has-revision' );
}
// Prune any output that depends on the revision ID.
- if ( $this->canonicalParserOutput ) {
- if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) {
- $this->canonicalParserOutput = null;
- }
- } else {
- $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" );
- }
-
- if ( $this->slotsOutput ) {
- foreach ( $this->slotsOutput as $role => $prep ) {
- if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) {
- unset( $this->slotsOutput[$role] );
- }
- }
- } else {
- $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" );
- }
-
- // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation
- $this->canonicalParserOptions = null;
-
- // Avoid re-generating the canonical ParserOutput if it's known.
- // We just trust that the caller is passing the correct ParserOutput!
- if ( isset( $options['parseroutput'] ) ) {
- $this->canonicalParserOutput = $options['parseroutput'];
+ if ( $this->renderedRevision ) {
+ $this->renderedRevision->updateRevision( $revision );
}
// TODO: optionally get ParserOutput from the ParserCache here.
// Move the logic used by RefreshLinksJob here!
}
- /**
- * @param ParserOutput $out
- * @param string $method
- * @return bool
- */
- private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) {
- if ( $out->getFlag( 'vary-revision' ) ) {
- // XXX: Just keep the output if the speculative revision ID was correct, like below?
- $this->saveParseLogger->info(
- "$method: Prepared output has vary-revision...\n"
- );
- return true;
- } elseif ( $out->getFlag( 'vary-revision-id' )
- && $out->getSpeculativeRevIdUsed() !== $this->revision->getId()
- ) {
- $this->saveParseLogger->info(
- "$method: Prepared output has vary-revision-id with wrong ID...\n"
- );
- return true;
- } elseif ( $out->getFlag( 'vary-user' )
- && !$this->options['changed']
- ) {
- // When Alice makes a null-edit on top of Bob's edit,
- // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261.
- // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions,
- // and set setCurrentRevisionCallback to return the existing revision when appropriate.
- // See also the comment there [dk 2018-05]
- $this->saveParseLogger->info(
- "$method: Prepared output has vary-user and is null-edit...\n"
- );
- return true;
- } else {
- wfDebug( "$method: Keeping prepared output...\n" );
- return false;
- }
- }
-
/**
* @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
* @return PreparedEdit
$preparedEdit->popts = $this->getCanonicalParserOptions();
$preparedEdit->output = $this->getCanonicalParserOutput();
- $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' );
+ $preparedEdit->pstContent = $this->revision->getContent( 'main' );
$preparedEdit->newContent =
$slotsUpdate->isModifiedSlot( 'main' )
? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
- : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this?
+ : $this->revision->getContent( 'main' ); // XXX: can we just remove this?
$preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
$preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
$preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
return $preparedEdit;
}
- /**
- * @return bool
- */
- private function isContentAccessible() {
- // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable!
- return $this->isContentPublic();
- }
-
/**
* @param string $role
* @param bool $generateHtml
* @return ParserOutput
*/
public function getSlotParserOutput( $role, $generateHtml = true ) {
- // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
-
- $this->assertPrepared( __METHOD__ );
-
- if ( isset( $this->slotsOutput[$role] ) ) {
- $entry = $this->slotsOutput[$role];
-
- if ( $entry->hasHtml || !$generateHtml ) {
- return $entry->output;
- }
- }
-
- if ( !$this->isContentAccessible() ) {
- // empty output
- $output = new ParserOutput();
- } else {
- $content = $this->getRawContent( $role );
-
- $output = $content->getParserOutput(
- $this->getTitle(),
- $this->revision ? $this->revision->getId() : null,
- $this->getCanonicalParserOptions(),
- $generateHtml
- );
- }
-
- $this->slotsOutput[$role] = (object)[
- 'output' => $output,
- 'hasHtml' => $generateHtml,
- ];
-
- $output->setCacheTime( $this->getTimestampNow() );
-
- return $output;
+ // XXX: $generateHtml is currently ignored. RenderedRevision could be made to use it.
+ return $this->getRenderedRevision()->getSlotParserOutput( $role );
}
/**
* @return ParserOutput
*/
public function getCanonicalParserOutput() {
- if ( $this->canonicalParserOutput ) {
- return $this->canonicalParserOutput;
- }
-
- // TODO: MCR: logic for combining the output of multiple slot goes here!
- // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
- $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' );
-
- return $this->canonicalParserOutput;
+ return $this->getRenderedRevision()->getRevisionParserOutput();
}
/**
* @return ParserOptions
*/
public function getCanonicalParserOptions() {
- if ( $this->canonicalParserOptions ) {
- return $this->canonicalParserOptions;
- }
-
- // TODO: ParserOptions should *not* be controlled by the ContentHandler!
- // See T190712 for how to fix this for Wikibase.
- $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' );
-
- //TODO: if $this->revision is not set but we already know that we pending update is a
- // null-edit, we should probably use the page's current revision here.
- // That would avoid the need for the !$this->options['changed'] branch in
- // outputVariesOnRevisionMetaData [dk 2018-05]
-
- if ( $this->revision ) {
- // Make sure we use the appropriate revision ID when generating output
- $title = $this->getTitle();
- $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback();
- $this->canonicalParserOptions->setCurrentRevisionCallback(
- function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) {
- if ( $parserTitle->equals( $title ) ) {
- $legacyRevision = new Revision( $this->revision );
- return $legacyRevision;
- } else {
- return call_user_func( $oldCallback, $parserTitle, $parser );
- }
- }
- );
- } else {
- // NOTE: we only get here without READ_LATEST if called directly by application logic
- $dbIndex = $this->useMaster()
- ? DB_MASTER // use the best possible guess
- : DB_REPLICA; // T154554
-
- $this->canonicalParserOptions->setSpeculativeRevIdCallback(
- function () use ( $dbIndex ) {
- // TODO: inject LoadBalancer!
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
- // Use a fresh connection in order to see the latest data, by avoiding
- // stale data from REPEATABLE-READ snapshots.
- // HACK: But don't use a fresh connection in unit tests, since it would not have
- // the fake tables. This should be handled by the LoadBalancer!
- $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTOCOMMIT;
- $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags );
-
- return 1 + (int)$db->selectField(
- 'revision',
- 'MAX(rev_id)',
- [],
- __METHOD__
- );
- }
- );
- }
-
- return $this->canonicalParserOptions;
+ return $this->getRenderedRevision()->getOptions();
}
/**
// TODO: make search infrastructure aware of slots!
$mainSlot = $this->revision->getSlot( 'main' );
- if ( !$mainSlot->isInherited() && $this->isContentPublic() ) {
+ if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
}
if ( $title->getNamespace() == NS_MEDIAWIKI
&& $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
) {
- $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null;
+ $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( 'main' );
$this->messageCache->updateMessageOverride( $title, $mainContent );
}
return $this->mSha1;
}
+ /**
+ * Returns the slots defined for this revision as a MutableRevisionSlots instance,
+ * which can be modified to defined the slots for this revision.
+ *
+ * @return MutableRevisionSlots
+ */
+ public function getSlots() {
+ // Overwritten just guarantee the more narrow return type.
+ return parent::getSlots();
+ }
+
/**
* Invalidate cached aggregate values such as hash and size.
*/
// TODO: MCR: check the role and the content's model against the list of supported
// roles, see T194046.
- if ( $role !== 'main' ) {
- throw new InvalidArgumentException( 'Only the main slot is presently supported' );
- }
-
$this->slotsUpdate->modifyContent( $role, $content );
}
$title = $this->getTitle();
$parent = $this->grabParentRevision();
- $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
+ // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
+ // TODO: introduce something like an UnsavedRevisionFactory service instead!
+ /** @var MutableRevisionRecord $rev */
+ $rev = $this->derivedDataUpdater->getRevision();
+
$rev->setPageId( $title->getArticleID() );
if ( $parent ) {
$rev->setTimestamp( $timestamp );
$rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
- foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
+ foreach ( $rev->getSlots()->getSlots() as $slot ) {
$content = $slot->getContent();
// XXX: We may push this up to the "edit controller" level, see T192777.
// TODO: change the signature of PrepareSave to not take a WikiPage!
$prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
- if ( $prepStatus->isOK() ) {
- $rev->setSlot( $slot );
- }
-
// TODO: MCR: record which problem arose in which slot.
$status->merge( $prepStatus );
}
}
$po = new ParserOutput();
+ $options->registerWatcher( [ $po, 'recordOption' ] );
if ( Hooks::run( 'ContentGetParserOutput',
[ $this, $title, $revId, $options, $generateHtml, &$po ] )
}
Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] );
+ $options->registerWatcher( null );
return $po;
}
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Storage\DerivedPageDataUpdater;
use MediaWiki\Storage\PageUpdater;
use MediaWiki\Storage\RevisionRecord;
return MediaWikiServices::getInstance()->getRevisionStore();
}
+ /**
+ * @return RevisionRenderer
+ */
+ private function getRevisionRenderer() {
+ return MediaWikiServices::getInstance()->getRevisionRenderer();
+ }
+
/**
* @return ParserCache
*/
// links.
$hasLinks = (bool)count( $editInfo->output->getLinks() );
} else {
+ // NOTE: keep in sync with revisionRenderer::getLinkCount
$hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
[ 'pl_from' => $this->getId() ], __METHOD__ );
}
$derivedDataUpdater = new DerivedPageDataUpdater(
$this, // NOTE: eventually, PageUpdater should not know about WikiPage
$this->getRevisionStore(),
+ $this->getRevisionRenderer(),
$this->getParserCache(),
JobQueueGroup::singleton(),
MessageCache::singleton(),
$this->mOutput->setFlag( 'vary-revision-id' );
wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" );
$value = $this->mRevisionId;
- if ( !$value && $this->mOptions->getSpeculativeRevIdCallback() ) {
- $value = call_user_func( $this->mOptions->getSpeculativeRevIdCallback() );
- $this->mOutput->setSpeculativeRevIdUsed( $value );
+
+ if ( !$value ) {
+ $rev = $this->getRevisionObject();
+ if ( $rev ) {
+ $value = $rev->getId();
+ }
+ }
+
+ if ( !$value ) {
+ $value = $this->mOptions->getSpeculativeRevId();
+ if ( $value ) {
+ $this->mOutput->setSpeculativeRevIdUsed( $value );
+ }
}
break;
case 'revisionday':
if ( !is_null( $this->mRevisionObject ) ) {
return $this->mRevisionObject;
}
- if ( is_null( $this->mRevisionId ) ) {
- return null;
- }
+ // NOTE: try to get the RevisionObject even if mRevisionId is null.
+ // This is useful when parsing revision that has not yet been saved.
$rev = call_user_func(
$this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
);
# If the parse is for a new revision, then the callback should have
# already been set to force the object and should match mRevisionId.
# If not, try to fetch by mRevisionId for sanity.
- if ( $rev && $rev->getId() != $this->mRevisionId ) {
+ if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
$rev = Revision::newFromId( $this->mRevisionId );
}
*/
private static $lazyOptions = [
'dateformat' => [ __CLASS__, 'initDateFormat' ],
+ 'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ],
];
/**
return $this->setOptionLegacy( 'templateCallback', $x );
}
+ /**
+ * A guess for {{REVISIONID}}, calculated using the callback provided via
+ * setSpeculativeRevIdCallback(). For consistency, the value will be calculated upon the
+ * first call of this method, and re-used for subsequent calls.
+ *
+ * If no callback was defined via setSpeculativeRevIdCallback(), this method will return false.
+ *
+ * @since 1.32
+ * @return int|false
+ */
+ public function getSpeculativeRevId() {
+ return $this->getOption( 'speculativeRevId' );
+ }
+
+ /**
+ * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativeRevId().
+ *
+ * @param ParserOptions $popt
+ * @return bool|false
+ */
+ private static function initSpeculativeRevId( ParserOptions $popt ) {
+ $cb = $popt->getOption( 'speculativeRevIdCallback' );
+ $id = $cb ? $cb() : null;
+
+ // returning null would result in this being re-called every access
+ return $id ?? false;
+ }
+
/**
* Callback to generate a guess for {{REVISIONID}}
* @since 1.28
+ * @deprecated since 1.32, use getSpeculativeRevId() instead!
* @return callable|null
*/
public function getSpeculativeRevIdCallback() {
* @return callable|null Old value
*/
public function setSpeculativeRevIdCallback( $x ) {
+ $this->setOption( 'speculativeRevId', null ); // reset
return $this->setOptionLegacy( 'speculativeRevIdCallback', $x );
}
'currentRevisionCallback' => [ Parser::class, 'statelessFetchRevision' ],
'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ],
'speculativeRevIdCallback' => null,
+ 'speculativeRevId' => null,
];
Hooks::run( 'ParserOptionsRegister', [
);
}
+ // Hydrate slot section header placeholders generated by RevisionRenderer.
+ $text = preg_replace_callback(
+ '#<mw:slotheader>(.*?)</mw:slotheader>#',
+ function ( $m ) {
+ $role = htmlspecialchars_decode( $m[1] );
+ // TODO: map to message, using the interface language. Set lang="xyz" accordingly.
+ $headerText = $role;
+ return $headerText;
+ },
+ $text
+ );
return $text;
}
return $this->mExternalLinks;
}
+ public function setNoGallery( $value ) {
+ $this->mNoGallery = (bool)$value;
+ }
public function getNoGallery() {
return $this->mNoGallery;
}
[ 'mParseStartTime' ]
);
}
+
+ /**
+ * Merges internal metadata such as flags, accessed options, and profiling info
+ * from $source into this ParserOutput. This should be used whenever the state of $source
+ * has any impact on the state of this ParserOutput.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeInternalMetaDataFrom( ParserOutput $source ) {
+ $this->mOutputHooks = self::mergeList( $this->mOutputHooks, $source->getOutputHooks() );
+ $this->mWarnings = self::mergeMap( $this->mWarnings, $source->mWarnings ); // don't use getter
+ $this->mTimestamp = $this->useMaxValue( $this->mTimestamp, $source->getTimestamp() );
+
+ if ( $this->mSpeculativeRevId && $source->mSpeculativeRevId
+ && $this->mSpeculativeRevId !== $source->mSpeculativeRevId
+ ) {
+ wfLogWarning(
+ 'Inconsistent speculative revision ID encountered while merging parser output!'
+ );
+ }
+
+ $this->mSpeculativeRevId = $this->useMaxValue(
+ $this->mSpeculativeRevId,
+ $source->getSpeculativeRevIdUsed()
+ );
+ $this->mParseStartTime = $this->useEachMinValue(
+ $this->mParseStartTime,
+ $source->mParseStartTime
+ );
+
+ $this->mFlags = self::mergeMap( $this->mFlags, $source->mFlags );
+ $this->mAccessedOptions = self::mergeMap( $this->mAccessedOptions, $source->mAccessedOptions );
+
+ // TODO: maintain per-slot limit reports!
+ if ( empty( $this->mLimitReportData ) ) {
+ $this->mLimitReportData = $source->mLimitReportData;
+ }
+ if ( empty( $this->mLimitReportJSData ) ) {
+ $this->mLimitReportJSData = $source->mLimitReportJSData;
+ }
+ }
+
+ /**
+ * Merges HTML metadata such as head items, JS config vars, and HTTP cache control info
+ * from $source into this ParserOutput. This should be used whenever the HTML in $source
+ * has been somehow mered into the HTML of this ParserOutput.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeHtmlMetaDataFrom( ParserOutput $source ) {
+ // HTML and HTTP
+ $this->mHeadItems = self::mergeMixedList( $this->mHeadItems, $source->getHeadItems() );
+ $this->mModules = self::mergeList( $this->mModules, $source->getModules() );
+ $this->mModuleScripts = self::mergeList( $this->mModuleScripts, $source->getModuleScripts() );
+ $this->mModuleStyles = self::mergeList( $this->mModuleStyles, $source->getModuleStyles() );
+ $this->mJsConfigVars = self::mergeMap( $this->mJsConfigVars, $source->getJsConfigVars() );
+ $this->mMaxAdaptiveExpiry = min( $this->mMaxAdaptiveExpiry, $source->mMaxAdaptiveExpiry );
+
+ // "noindex" always wins!
+ if ( $this->mIndexPolicy === 'noindex' || $source->mIndexPolicy === 'noindex' ) {
+ $this->mIndexPolicy = 'noindex';
+ } elseif ( $this->mIndexPolicy !== 'index' ) {
+ $this->mIndexPolicy = $source->mIndexPolicy;
+ }
+
+ // Skin control
+ $this->mNewSection = $this->mNewSection || $source->getNewSection();
+ $this->mHideNewSection = $this->mHideNewSection || $source->getHideNewSection();
+ $this->mNoGallery = $this->mNoGallery || $source->getNoGallery();
+ $this->mEnableOOUI = $this->mEnableOOUI || $source->getEnableOOUI();
+ $this->mPreventClickjacking = $this->mPreventClickjacking || $source->preventClickjacking();
+
+ // TODO: we'll have to be smarter about this!
+ $this->mSections = array_merge( $this->mSections, $source->getSections() );
+ $this->mTOCHTML = $this->mTOCHTML . $source->mTOCHTML;
+
+ // XXX: we don't want to concatenate title text, so first write wins.
+ // We should use the first *modified* title text, but we don't have the original to check.
+ if ( $this->mTitleText === null || $this->mTitleText === '' ) {
+ $this->mTitleText = $source->mTitleText;
+ }
+
+ // class names are stored in array keys
+ $this->mWrapperDivClasses = self::mergeMap(
+ $this->mWrapperDivClasses,
+ $source->mWrapperDivClasses
+ );
+
+ // NOTE: last write wins, same as within one ParserOutput
+ $this->mIndicators = self::mergeMap( $this->mIndicators, $source->getIndicators() );
+
+ // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+ // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+ // kinds of extension data to be merged in different ways.
+ $this->mExtensionData = self::mergeMap(
+ $this->mExtensionData,
+ $source->mExtensionData
+ );
+ }
+
+ /**
+ * Merges dependency tracking metadata such as backlinks, images used, and extension data
+ * from $source into this ParserOutput. This allows dependency tracking to be done for the
+ * combined output of multiple content slots.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeTrackingMetaDataFrom( ParserOutput $source ) {
+ $this->mLanguageLinks = self::mergeList( $this->mLanguageLinks, $source->getLanguageLinks() );
+ $this->mCategories = self::mergeMap( $this->mCategories, $source->getCategories() );
+ $this->mLinks = self::merge2D( $this->mLinks, $source->getLinks() );
+ $this->mTemplates = self::merge2D( $this->mTemplates, $source->getTemplates() );
+ $this->mTemplateIds = self::merge2D( $this->mTemplateIds, $source->getTemplateIds() );
+ $this->mImages = self::mergeMap( $this->mImages, $source->getImages() );
+ $this->mFileSearchOptions = self::mergeMap(
+ $this->mFileSearchOptions,
+ $source->getFileSearchOptions()
+ );
+ $this->mExternalLinks = self::mergeMap( $this->mExternalLinks, $source->getExternalLinks() );
+ $this->mInterwikiLinks = self::merge2D(
+ $this->mInterwikiLinks,
+ $source->getInterwikiLinks()
+ );
+
+ // TODO: add a $mergeStrategy parameter to setProperty to allow different
+ // kinds of properties to be merged in different ways.
+ $this->mProperties = self::mergeMap( $this->mProperties, $source->getProperties() );
+
+ // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+ // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+ // kinds of extension data to be merged in different ways.
+ $this->mExtensionData = self::mergeMap(
+ $this->mExtensionData,
+ $source->mExtensionData
+ );
+ }
+
+ private static function mergeMixedList( array $a, array $b ) {
+ return array_unique( array_merge( $a, $b ), SORT_REGULAR );
+ }
+
+ private static function mergeList( array $a, array $b ) {
+ return array_values( array_unique( array_merge( $a, $b ), SORT_REGULAR ) );
+ }
+
+ private static function mergeMap( array $a, array $b ) {
+ return array_replace( $a, $b );
+ }
+
+ private static function merge2D( array $a, array $b ) {
+ $values = [];
+ $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+ foreach ( $keys as $k ) {
+ if ( empty( $a[$k] ) ) {
+ $values[$k] = $b[$k];
+ } elseif ( empty( $b[$k] ) ) {
+ $values[$k] = $a[$k];
+ } elseif ( is_array( $a[$k] ) && is_array( $b[$k] ) ) {
+ $values[$k] = array_replace( $a[$k], $b[$k] );
+ } else {
+ $values[$k] = $b[$k];
+ }
+ }
+
+ return $values;
+ }
+
+ private static function useEachMinValue( array $a, array $b ) {
+ $values = [];
+ $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+ foreach ( $keys as $k ) {
+ if ( is_array( $a[$k] ?? null ) && is_array( $b[$k] ?? null ) ) {
+ $values[$k] = self::useEachMinValue( $a[$k], $b[$k] );
+ } else {
+ $values[$k] = self::useMinValue( $a[$k] ?? null, $b[$k] ?? null );
+ }
+ }
+
+ return $values;
+ }
+
+ private static function useMinValue( $a, $b ) {
+ if ( $a === null ) {
+ return $b;
+ }
+
+ if ( $b === null ) {
+ return $a;
+ }
+
+ return min( $a, $b );
+ }
+
+ private static function useMaxValue( $a, $b ) {
+ if ( $a === null ) {
+ return $b;
+ }
+
+ if ( $b === null ) {
+ return $a;
+ }
+
+ return max( $a, $b );
+ }
+
}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Language;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RenderedRevision
+ */
+class RenderedRevisionTest extends MediaWikiTestCase {
+
+ /** @var callable */
+ private $combinerCallback;
+
+ public function setUp() {
+ parent::setUp();
+
+ $this->combinerCallback = function ( RenderedRevision $rr ) {
+ return $this->combineOutput( $rr );
+ };
+ }
+
+ private function combineOutput( RenderedRevision $rrev ) {
+ $revision = $rrev->getRevision();
+ $slots = $revision->getSlots()->getSlots();
+
+ $output = new ParserOutput();
+ $html = '';
+ foreach ( $slots as $role => $slot ) {
+
+ if ( $html !== '' ) {
+ // skip header for the first slot
+ $html .= "(($role))";
+ }
+
+ $slotOutput = $rrev->getSlotParserOutput( $role );
+ $html .= $slotOutput->getRawText();
+
+ $output->mergeInternalMetaDataFrom( $slotOutput, $role );
+ $output->mergeHtmlMetaDataFrom( $slotOutput );
+ $output->mergeTrackingMetaDataFrom( $slotOutput );
+ }
+
+ $output->setText( $html );
+ return $output;
+ }
+
+ /**
+ * @param $articleId
+ * @param $revisionId
+ * @return Title
+ */
+ private function getMockTitle( $articleId, $revisionId ) {
+ /** @var Title|MockObject $mock */
+ $mock = $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( NS_MAIN ) );
+ $mock->expects( $this->any() )
+ ->method( 'getText' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getPrefixedText' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getDBkey' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getArticleID' )
+ ->will( $this->returnValue( $articleId ) );
+ $mock->expects( $this->any() )
+ ->method( 'getLatestRevId' )
+ ->will( $this->returnValue( $revisionId ) );
+ $mock->expects( $this->any() )
+ ->method( 'getContentModel' )
+ ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+ $mock->expects( $this->any() )
+ ->method( 'getPageLanguage' )
+ ->will( $this->returnValue( Language::factory( 'en' ) ) );
+ $mock->expects( $this->any() )
+ ->method( 'isContentPage' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'equals' )
+ ->willReturnCallback( function ( Title $other ) use ( $mock ) {
+ return $mock->getArticleID() === $other->getArticleID();
+ } );
+ $mock->expects( $this->any() )
+ ->method( 'userCan' )
+ ->willReturnCallback( function ( $perm, User $user ) use ( $mock ) {
+ return $user->isAllowed( $perm );
+ } );
+
+ return $mock;
+ }
+
+ public function testGetRevisionParserOutput_new() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+ $text .= "* [[Link It]]\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+ }
+
+ public function testGetRevisionParserOutput_current() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 21 ); // current!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:21', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRevisionParserOutput_old() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRevisionParserOutput_suppressed() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $this->setExpectedException( SuppressedDataException::class );
+ $rr->getRevisionParserOutput();
+ }
+
+ public function testGetRevisionParserOutput_privileged() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+ $rr = new RenderedRevision(
+ $title,
+ $rev,
+ $options,
+ $this->combinerCallback,
+ RevisionRecord::FOR_THIS_USER,
+ $sysop
+ );
+
+ $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ // Suppressed content should be visible for sysops
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRevisionParserOutput_raw() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision(
+ $title,
+ $rev,
+ $options,
+ $this->combinerCallback,
+ RevisionRecord::RAW
+ );
+
+ $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ // Suppressed content should be visible for sysops
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_multi() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+ $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $combinedOutput = $rr->getRevisionParserOutput();
+ $mainOutput = $rr->getSlotParserOutput( 'main' );
+ $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+ $combinedHtml = $combinedOutput->getText();
+ $mainHtml = $mainOutput->getText();
+ $auxHtml = $auxOutput->getText();
+
+ $this->assertContains( 'Kittens', $mainHtml );
+ $this->assertContains( 'Goats', $auxHtml );
+ $this->assertNotContains( 'Goats', $mainHtml );
+ $this->assertNotContains( 'Kittens', $auxHtml );
+ $this->assertContains( 'Kittens', $combinedHtml );
+ $this->assertContains( 'Goats', $combinedHtml );
+ $this->assertContains( 'aux', $combinedHtml, 'slot section header' );
+
+ $combinedLinks = $combinedOutput->getLinks();
+ $mainLinks = $mainOutput->getLinks();
+ $auxLinks = $auxOutput->getLinks();
+ $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+ $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+ $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+ $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+ }
+
+ public function testUpdateRevision() {
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+ $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+ $firstOutput = $rr->getRevisionParserOutput();
+ $mainOutput = $rr->getSlotParserOutput( 'main' );
+ $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+ // emulate a saved revision
+ $savedRev = new MutableRevisionRecord( $title );
+ $savedRev->setContent( 'main', new WikitextContent( $text ) );
+ $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+ $savedRev->setId( 23 ); // saved, new
+ $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $savedRev->setTimestamp( '20180101000003' );
+
+ $rr->updateRevision( $savedRev );
+
+ $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( 'main' ), 'Reset main' );
+ $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );
+
+ $updatedOutput = $rr->getRevisionParserOutput();
+ $html = $updatedOutput->getText();
+
+ $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:23', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+ $this->assertContains( 'Goats', $html );
+
+ $rr->updateRevision( $savedRev ); // should do nothing
+ $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );
+ }
+
+}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Language;
+use LogicException;
+use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionRenderer
+ */
+class RevisionRendererTest extends MediaWikiTestCase {
+
+ /**
+ * @param $articleId
+ * @param $revisionId
+ * @return Title
+ */
+ private function getMockTitle( $articleId, $revisionId ) {
+ /** @var Title|MockObject $mock */
+ $mock = $this->getMockBuilder( Title::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $mock->expects( $this->any() )
+ ->method( 'getNamespace' )
+ ->will( $this->returnValue( NS_MAIN ) );
+ $mock->expects( $this->any() )
+ ->method( 'getText' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getPrefixedText' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getDBkey' )
+ ->will( $this->returnValue( __CLASS__ ) );
+ $mock->expects( $this->any() )
+ ->method( 'getArticleID' )
+ ->will( $this->returnValue( $articleId ) );
+ $mock->expects( $this->any() )
+ ->method( 'getLatestRevId' )
+ ->will( $this->returnValue( $revisionId ) );
+ $mock->expects( $this->any() )
+ ->method( 'getContentModel' )
+ ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+ $mock->expects( $this->any() )
+ ->method( 'getPageLanguage' )
+ ->will( $this->returnValue( Language::factory( 'en' ) ) );
+ $mock->expects( $this->any() )
+ ->method( 'isContentPage' )
+ ->will( $this->returnValue( true ) );
+ $mock->expects( $this->any() )
+ ->method( 'equals' )
+ ->willReturnCallback(
+ function ( Title $other ) use ( $mock ) {
+ return $mock->getArticleID() === $other->getArticleID();
+ }
+ );
+ $mock->expects( $this->any() )
+ ->method( 'userCan' )
+ ->willReturnCallback(
+ function ( $perm, User $user ) use ( $mock ) {
+ return $user->isAllowed( $perm );
+ }
+ );
+
+ return $mock;
+ }
+
+ /**
+ * @param int $maxRev
+ * @param int $linkCount
+ *
+ * @return IDatabase
+ */
+ private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) {
+ /** @var IDatabase|MockObject $db */
+ $db = $this->getMock( IDatabase::class );
+ $db->method( 'selectField' )
+ ->willReturnCallback(
+ function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) {
+ return $this->selectFieldCallback(
+ $table,
+ $fields,
+ $cond,
+ $maxRev,
+ $linkCount
+ );
+ }
+ );
+
+ return $db;
+ }
+
+ /**
+ * @return RevisionRenderer
+ */
+ private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) {
+ $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA;
+
+ $db = $this->getMockDatabaseConnection( $maxRev );
+
+ /** @var ILoadBalancer|MockObject $lb */
+ $lb = $this->getMock( ILoadBalancer::class );
+ $lb->method( 'getConnection' )
+ ->with( $dbIndex )
+ ->willReturn( $db );
+ $lb->method( 'getConnectionRef' )
+ ->with( $dbIndex )
+ ->willReturn( $db );
+ $lb->method( 'getLazyConnectionRef' )
+ ->with( $dbIndex )
+ ->willReturn( $db );
+
+ return new RevisionRenderer( $lb );
+ }
+
+ private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
+ if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) {
+ return $maxRev;
+ }
+
+ $this->fail( 'Unexpected call to selectField' );
+ throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy
+ }
+
+ public function testGetRenderedRevision_new() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+ $text .= "* [[Link It]]\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision( $rev, $options );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:101', $html ); // from speculativeRevIdCallback
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_current() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 21 ); // current!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision( $rev, $options );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:21', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_master() {
+ $renderer = $this->newRevisionRenderer( 100, true ); // use master
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 21 ); // current!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'rev:21', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_old() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision( $rev, $options );
+
+ $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_suppressed() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision( $rev, $options );
+
+ $this->assertNull( $rr, 'getRenderedRevision' );
+ }
+
+ public function testGetRenderedRevision_privileged() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+ $rr = $renderer->getRenderedRevision( $rev, $options, $sysop );
+
+ $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ // Suppressed content should be visible for sysops
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_raw() {
+ $renderer = $this->newRevisionRenderer( 100 );
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( 11 ); // old!
+ $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $text = "";
+ $text .= "* page:{{PAGENAME}}\n";
+ $text .= "* rev:{{REVISIONID}}\n";
+ $text .= "* user:{{REVISIONUSER}}\n";
+ $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+ $rev->setContent( 'main', new WikitextContent( $text ) );
+
+ $options = ParserOptions::newCanonical( 'canonical' );
+ $rr = $renderer->getRenderedRevision(
+ $rev,
+ $options,
+ null,
+ [ 'audience' => RevisionRecord::RAW ]
+ );
+
+ $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+ $this->assertSame( $rev, $rr->getRevision() );
+ $this->assertSame( $options, $rr->getOptions() );
+
+ $html = $rr->getRevisionParserOutput()->getText();
+
+ // Suppressed content should be visible in raw mode
+ $this->assertContains( 'page:' . __CLASS__, $html );
+ $this->assertContains( 'rev:11', $html );
+ $this->assertContains( 'user:Frank', $html );
+ $this->assertContains( 'time:20180101000003', $html );
+
+ $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+ }
+
+ public function testGetRenderedRevision_multi() {
+ $renderer = $this->newRevisionRenderer();
+ $title = $this->getMockTitle( 7, 21 );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+ $rev->setTimestamp( '20180101000003' );
+
+ $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+ $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+ $rr = $renderer->getRenderedRevision( $rev );
+
+ $combinedOutput = $rr->getRevisionParserOutput();
+ $mainOutput = $rr->getSlotParserOutput( 'main' );
+ $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+ $combinedHtml = $combinedOutput->getText();
+ $mainHtml = $mainOutput->getText();
+ $auxHtml = $auxOutput->getText();
+
+ $this->assertContains( 'Kittens', $mainHtml );
+ $this->assertContains( 'Goats', $auxHtml );
+ $this->assertNotContains( 'Goats', $mainHtml );
+ $this->assertNotContains( 'Kittens', $auxHtml );
+ $this->assertContains( 'Kittens', $combinedHtml );
+ $this->assertContains( 'Goats', $combinedHtml );
+ $this->assertContains( '>aux<', $combinedHtml, 'slot header' );
+ $this->assertNotContains( '<mw:slotheader', $combinedHtml, 'slot header placeholder' );
+
+ // make sure output wrapping works right
+ $this->assertContains( 'class="mw-parser-output"', $mainHtml );
+ $this->assertContains( 'class="mw-parser-output"', $auxHtml );
+ $this->assertContains( 'class="mw-parser-output"', $combinedHtml );
+
+ // there should be only one wrapper div
+ $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) );
+ $this->assertNotContains( 'class="mw-parser-output"', $combinedOutput->getRawText() );
+
+ $combinedLinks = $combinedOutput->getLinks();
+ $mainLinks = $mainOutput->getLinks();
+ $auxLinks = $auxOutput->getLinks();
+ $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+ $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+ $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+ $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+ }
+
+}
$user = $this->getTestUser()->getUser();
$comment = CommentStoreComment::newUnsavedComment( $summary );
- if ( !$content instanceof Content ) {
+ if ( $content === null || is_string( $content ) ) {
$content = new WikitextContent( $content ?? $summary );
}
+ if ( !is_array( $content ) ) {
+ $content = [ 'main' => $content ];
+ }
+
$this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
$updater = $page->newPageUpdater( $user );
- $updater->setContent( 'main', $content );
+
+ foreach ( $content as $role => $c ) {
+ $updater->setContent( $role, $c );
+ }
+
$rev = $updater->saveRevision( $comment );
$this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
$this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(),
$options1->getUserLangObj() );
- $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() );
+ $speculativeId = $options1->getSpeculativeRevId();
$this->assertSame( $parentRev->getId() + 1, $speculativeId );
$rev = $this->makeRevision(
$updater->prepareUpdate( $rev );
$options2 = $updater->getCanonicalParserOptions();
- $this->assertNotSame( $options1, $options2 );
$currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
$this->assertSame( $rev->getId(), $currentRev->getId() );
* @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
*/
public function testPrepareContent() {
- $user = $this->getTestUser()->getUser();
+ $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
$updater = $this->getDerivedPageDataUpdater( __METHOD__ );
$this->assertFalse( $updater->isContentPrepared() );
$update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
// TODO: MCR: test removing slots!
- $updater->prepareContent( $user, $update, false );
+ $updater->prepareContent( $sysop, $update, false );
// second be ok to call again with the same params
- $updater->prepareContent( $user, $update, false );
+ $updater->prepareContent( $sysop, $update, false );
$this->assertNull( $updater->grabCurrentRevision() );
$this->assertTrue( $updater->isContentPrepared() );
$this->assertFalse( $updater->pageExisted() );
$this->assertTrue( $updater->isCreation() );
$this->assertTrue( $updater->isChange() );
- $this->assertTrue( $updater->isContentPublic() );
+ $this->assertFalse( $updater->isContentDeleted() );
+
+ $this->assertNotNull( $updater->getRevision() );
+ $this->assertNotNull( $updater->getRenderedRevision() );
$this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
$this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
$mainSlot = $updater->getRawSlot( 'main' );
$this->assertInstanceOf( SlotRecord::class, $mainSlot );
$this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
- $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() );
+ $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() );
$auxSlot = $updater->getRawSlot( 'aux' );
$this->assertInstanceOf( SlotRecord::class, $auxSlot );
$canonicalOutput = $updater->getCanonicalParserOutput();
$this->assertContains( 'first', $canonicalOutput->getText() );
$this->assertContains( '<a ', $canonicalOutput->getText() );
+ $this->assertContains( 'inherited ', $canonicalOutput->getText() );
$this->assertNotEmpty( $canonicalOutput->getLinks() );
}
* @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
*/
public function testPrepareContentInherit() {
- $user = $this->getTestUser()->getUser();
+ $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
$page = $this->getPage( __METHOD__ );
- $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+ $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) ~~~' );
$mainContent2 = new WikitextContent( 'second' );
- $this->createRevision( $page, 'first', $mainContent1 );
+ $rev = $this->createRevision( $page, 'first', $mainContent1 );
+ $mainContent1 = $rev->getContent( 'main' ); // get post-pst content
$update = new RevisionSlotsUpdate();
$update->modifyContent( 'main', $mainContent1 );
$updater1 = $this->getDerivedPageDataUpdater( $page );
- $updater1->prepareContent( $user, $update, false );
+ $updater1->prepareContent( $sysop, $update, false );
$this->assertNotNull( $updater1->grabCurrentRevision() );
$this->assertTrue( $updater1->isContentPrepared() );
$this->assertFalse( $updater1->isCreation() );
$this->assertFalse( $updater1->isChange() );
+ $this->assertNotNull( $updater1->getRevision() );
+ $this->assertNotNull( $updater1->getRenderedRevision() );
+
+ // parser-output for null-edit uses the original author's name
+ $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
+ $this->assertNotContains( $sysop->getName(), $html, '{{REVISIONUSER}}' );
+ $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
+ $this->assertContains( '(' . $rev->getUser()->getName() . ')', $html, '{{REVISIONUSER}}' );
+
// TODO: MCR: test inheritance from parent
$update = new RevisionSlotsUpdate();
$update->modifyContent( 'main', $mainContent2 );
$updater2 = $this->getDerivedPageDataUpdater( $page );
- $updater2->prepareContent( $user, $update, false );
+ $updater2->prepareContent( $sysop, $update, false );
$this->assertFalse( $updater2->isCreation() );
$this->assertTrue( $updater2->isChange() );
$this->assertTrue( $updater1->isContentPrepared() );
$this->assertTrue( $updater1->isCreation() );
$this->assertTrue( $updater1->isChange() );
- $this->assertTrue( $updater1->isContentPublic() );
+ $this->assertFalse( $updater1->isContentDeleted() );
+
+ $this->assertNotNull( $updater1->getRevision() );
+ $this->assertNotNull( $updater1->getRenderedRevision() );
$this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
$this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
public function testDoUpdates() {
$page = $this->getPage( __METHOD__ );
- $mainContent1 = new WikitextContent( 'first [[main]]' );
- $rev = $this->createRevision( $page, 'first', $mainContent1 );
+ $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
+
+ if ( $this->hasMultiSlotSupport() ) {
+ $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
+ }
+
+ $rev = $this->createRevision( $page, 'first', $content );
$pageId = $page->getId();
+
$oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+ $this->db->delete( 'pagelinks', '*' );
+
+ $pcache = MediaWikiServices::getInstance()->getParserCache();
+ $pcache->deleteOptionsKey( $page );
$updater = $this->getDerivedPageDataUpdater( $page, $rev );
$updater->setArticleCountMethod( 'link' );
$updater->doUpdates();
// links table update
- $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
- $this->assertSame( 1, $linkCount );
+ $pageLinks = $this->db->select(
+ 'pagelinks',
+ '*',
+ [ 'pl_from' => $pageId ],
+ __METHOD__,
+ [ 'ORDER BY' => 'pl_namespace, pl_title' ]
+ );
- $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+ $pageLinksRow = $pageLinks->fetchObject();
$this->assertInternalType( 'object', $pageLinksRow );
$this->assertSame( 'Main', $pageLinksRow->pl_title );
+ if ( $this->hasMultiSlotSupport() ) {
+ $pageLinksRow = $pageLinks->fetchObject();
+ $this->assertInternalType( 'object', $pageLinksRow );
+ $this->assertSame( 'Nix', $pageLinksRow->pl_title );
+ }
+
// parser cache update
- $pcache = MediaWikiServices::getInstance()->getParserCache();
$cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
$this->assertInternalType( 'object', $cached );
$this->assertSame( $updater->getCanonicalParserOutput(), $cached );
// TODO: test category membership update (with setRcWatchCategoryMembership())
}
+ private function hasMultiSlotSupport() {
+ global $wgMultiContentRevisionSchemaMigrationStage;
+
+ return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW )
+ && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW );
+ }
+
}
use CommentStoreComment;
use InvalidArgumentException;
use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionAccessException;
use MediaWiki\Storage\RevisionRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
$this->assertSame( 'someHash', $record->getSha1() );
}
+ public function testGetSlots() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() );
+ }
+
public function testSetGetSize() {
$record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
$this->assertSame( 0, $record->getSize() );
use MediaWiki\MediaWikiServices;
use MediaWiki\Storage\RevisionRecord;
use MediaWikiTestCase;
+use ParserOptions;
use RecentChange;
use Revision;
use TextContent;
);
}
+ public function provideMagicWords() {
+ yield 'PAGEID' => [
+ 'Test {{PAGEID}} Test',
+ function ( RevisionRecord $rev ) {
+ return $rev->getPageId();
+ }
+ ];
+
+ yield 'REVISIONID' => [
+ 'Test {{REVISIONID}} Test',
+ function ( RevisionRecord $rev ) {
+ return $rev->getId();
+ }
+ ];
+
+ yield 'REVISIONUSER' => [
+ 'Test {{REVISIONUSER}} Test',
+ function ( RevisionRecord $rev ) {
+ return $rev->getUser()->getName();
+ }
+ ];
+
+ yield 'REVISIONTIMESTAMP' => [
+ 'Test {{REVISIONTIMESTAMP}} Test',
+ function ( RevisionRecord $rev ) {
+ return $rev->getTimestamp();
+ }
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ *
+ * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer
+ * and RenderedRevision, that ensures that magic words depending on revision meta-data
+ * are handled correctly. Note that each magic word needs to be tested separately,
+ * to assert correct behavior for each "vary" flag in the ParserOutput.
+ *
+ * @dataProvider provideMagicWords
+ */
+ public function testMagicWords( $wikitext, $callback ) {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() );
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+
+ $updater->setContent( 'main', new \WikitextContent( $wikitext ) );
+
+ $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+ $rev = $updater->saveRevision( $summary, EDIT_NEW );
+
+ if ( !$rev ) {
+ $this->fail( $updater->getStatus()->getWikiText() );
+ }
+
+ $expected = strval( $callback( $rev ) );
+
+ $cache = MediaWikiServices::getInstance()->getParserCache();
+ $output = $cache->get(
+ $page,
+ ParserOptions::newCanonical(
+ 'canonical'
+ )
+ );
+
+ $this->assertNotNull( $output, 'ParserCache::get' );
+
+ $this->assertContains( $expected, $output->getText() );
+ }
+
}
$wrap->defaults = null;
$wrap->lazyOptions = [
'dateformat' => [ ParserOptions::class, 'initDateFormat' ],
+ 'speculativeRevId' => [ ParserOptions::class, 'initSpeculativeRevId' ],
];
$wrap->inCacheKey = [
'dateformat' => true,
], ParserOptions::allCacheVaryingOptions() );
}
+ public function testGetSpeculativeRevid() {
+ $options = new ParserOptions();
+
+ $this->assertFalse( $options->getSpeculativeRevId() );
+
+ $counter = 0;
+ $options->setSpeculativeRevIdCallback( function () use( &$counter ) {
+ return ++$counter;
+ } );
+
+ // make sure the same value is re-used once it is determined
+ $this->assertSame( 1, $options->getSpeculativeRevId() );
+ $this->assertSame( 1, $options->getSpeculativeRevId() );
+ }
+
}
<?php
+use Wikimedia\TestingAccessWrapper;
/**
* @group Database
* ^--- trigger DB shadowing because we are using Title magic
*/
-class ParserOutputTest extends MediaWikiTestCase {
+class ParserOutputTest extends MediaWikiLangTestCase {
public static function provideIsLinkInternal() {
return [
// phpcs:enable
}
+ public function provideMergeHtmlMetaDataFrom() {
+ // title text ------------
+ $a = new ParserOutput();
+ $a->setTitleText( 'X' );
+ $b = new ParserOutput();
+ yield 'only left title text' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+ $a = new ParserOutput();
+ $b = new ParserOutput();
+ $b->setTitleText( 'Y' );
+ yield 'only right title text' => [ $a, $b, [ 'getTitleText' => 'Y' ] ];
+
+ $a = new ParserOutput();
+ $a->setTitleText( 'X' );
+ $b = new ParserOutput();
+ $b->setTitleText( 'Y' );
+ yield 'left title text wins' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+ // index policy ------------
+ $a = new ParserOutput();
+ $a->setIndexPolicy( 'index' );
+ $b = new ParserOutput();
+ yield 'only left index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+ $a = new ParserOutput();
+ $b = new ParserOutput();
+ $b->setIndexPolicy( 'index' );
+ yield 'only right index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+ $a = new ParserOutput();
+ $a->setIndexPolicy( 'noindex' );
+ $b = new ParserOutput();
+ $b->setIndexPolicy( 'index' );
+ yield 'left noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+ $a = new ParserOutput();
+ $a->setIndexPolicy( 'index' );
+ $b = new ParserOutput();
+ $b->setIndexPolicy( 'noindex' );
+ yield 'right noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+ // head items and friends ------------
+ $a = new ParserOutput();
+ $a->addHeadItem( '<foo1>' );
+ $a->addHeadItem( '<bar1>', 'bar' );
+ $a->addModules( 'test-module-a' );
+ $a->addModuleScripts( 'test-module-script-a' );
+ $a->addModuleStyles( 'test-module-styles-a' );
+ $b->addJsConfigVars( 'test-config-var-a', 'a' );
+
+ $b = new ParserOutput();
+ $b->setIndexPolicy( 'noindex' );
+ $b->addHeadItem( '<foo2>' );
+ $b->addHeadItem( '<bar2>', 'bar' );
+ $b->addModules( 'test-module-b' );
+ $b->addModuleScripts( 'test-module-script-b' );
+ $b->addModuleStyles( 'test-module-styles-b' );
+ $b->addJsConfigVars( 'test-config-var-b', 'b' );
+ $b->addJsConfigVars( 'test-config-var-a', 'X' );
+
+ yield 'head items and friends' => [ $a, $b, [
+ 'getHeadItems' => [
+ '<foo1>',
+ '<foo2>',
+ 'bar' => '<bar2>', // overwritten
+ ],
+ 'getModules' => [
+ 'test-module-a',
+ 'test-module-b',
+ ],
+ 'getModuleScripts' => [
+ 'test-module-script-a',
+ 'test-module-script-b',
+ ],
+ 'getModuleStyles' => [
+ 'test-module-styles-a',
+ 'test-module-styles-b',
+ ],
+ 'getJsConfigVars' => [
+ 'test-config-var-a' => 'X', // overwritten
+ 'test-config-var-b' => 'b',
+ ],
+ ] ];
+
+ // TOC ------------
+ $a = new ParserOutput();
+ $a->setTOCHTML( '<p>TOC A</p>' );
+ $a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] );
+
+ $b = new ParserOutput();
+ $b->setTOCHTML( '<p>TOC B</p>' );
+ $b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] );
+
+ yield 'concat TOC' => [ $a, $b, [
+ 'getTOCHTML' => '<p>TOC A</p><p>TOC B</p>',
+ 'getSections' => [
+ [ 'fromtitle' => 'A1' ],
+ [ 'fromtitle' => 'A2' ],
+ [ 'fromtitle' => 'B1' ],
+ [ 'fromtitle' => 'B2' ]
+ ],
+ ] ];
+
+ // Skin Control ------------
+ $a = new ParserOutput();
+ $a->setNewSection( true );
+ $a->hideNewSection( true );
+ $a->setNoGallery( true );
+ $a->addWrapperDivClass( 'foo' );
+
+ $a->setIndicator( 'foo', 'Foo!' );
+ $a->setIndicator( 'bar', 'Bar!' );
+
+ $a->setExtensionData( 'foo', 'Foo!' );
+ $a->setExtensionData( 'bar', 'Bar!' );
+
+ $b = new ParserOutput();
+ $b->setNoGallery( true );
+ $b->setEnableOOUI( true );
+ $b->preventClickjacking( true );
+ $a->addWrapperDivClass( 'bar' );
+
+ $b->setIndicator( 'zoo', 'Zoo!' );
+ $b->setIndicator( 'bar', 'Barrr!' );
+
+ $b->setExtensionData( 'zoo', 'Zoo!' );
+ $b->setExtensionData( 'bar', 'Barrr!' );
+
+ yield 'skin control flags' => [ $a, $b, [
+ 'getNewSection' => true,
+ 'getHideNewSection' => true,
+ 'getNoGallery' => true,
+ 'getEnableOOUI' => true,
+ 'preventClickjacking' => true,
+ 'getIndicators' => [
+ 'foo' => 'Foo!',
+ 'bar' => 'Barrr!',
+ 'zoo' => 'Zoo!',
+ ],
+ 'getWrapperDivClass' => 'foo bar',
+ '$mExtensionData' => [
+ 'foo' => 'Foo!',
+ 'bar' => 'Barrr!',
+ 'zoo' => 'Zoo!',
+ ],
+ ] ];
+ }
+
+ /**
+ * @dataProvider provideMergeHtmlMetaDataFrom
+ * @covers ParserOutput::mergeHtmlMetaDataFrom
+ *
+ * @param ParserOutput $a
+ * @param ParserOutput $b
+ * @param array $expected
+ */
+ public function testMergeHtmlMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+ $a->mergeHtmlMetaDataFrom( $b );
+
+ $this->assertFieldValues( $a, $expected );
+
+ // test twice, to make sure the operation is idempotent (except for the TOC, see below)
+ $a->mergeHtmlMetaDataFrom( $b );
+
+ // XXX: TOC joining should get smarter. Can we make it idempotent as well?
+ unset( $expected['getTOCHTML'] );
+ unset( $expected['getSections'] );
+
+ $this->assertFieldValues( $a, $expected );
+ }
+
+ private function assertFieldValues( ParserOutput $po, $expected ) {
+ $po = TestingAccessWrapper::newFromObject( $po );
+
+ foreach ( $expected as $method => $value ) {
+ if ( $method[0] === '$' ) {
+ $field = substr( $method, 1 );
+ $actual = $po->__get( $field );
+ } else {
+ $actual = $po->__call( $method, [] );
+ }
+
+ $this->assertEquals( $value, $actual, $method );
+ }
+ }
+
+ public function provideMergeTrackingMetaDataFrom() {
+ // links ------------
+ $a = new ParserOutput();
+ $a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 );
+ $a->addLink( Title::makeTitle( NS_TALK, 'Kittens' ), 16 );
+ $a->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+
+ $a->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Goats' ), 107, 1107 );
+
+ $a->addLanguageLink( 'de' );
+ $a->addLanguageLink( 'ru' );
+ $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens DE', '', 'de' ) );
+ $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens RU', '', 'ru' ) );
+ $a->addExternalLink( 'https://kittens.wikimedia.test' );
+ $a->addExternalLink( 'https://goats.wikimedia.test' );
+
+ $a->addCategory( 'Foo', 'X' );
+ $a->addImage( 'Billy.jpg', '20180101000013', 'DEAD' );
+
+ $b = new ParserOutput();
+ $b->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+ $b->addLink( Title::makeTitle( NS_TALK, 'Goats' ), 17 );
+ $b->addLink( Title::makeTitle( NS_MAIN, 'Dragons' ), 8 );
+ $b->addLink( Title::makeTitle( NS_FILE, 'Dragons.jpg' ), 28 );
+
+ $b->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Dragons' ), 108, 1108 );
+ $a->addTemplate( Title::makeTitle( NS_MAIN, 'Dragons' ), 118, 1118 );
+
+ $b->addLanguageLink( 'fr' );
+ $b->addLanguageLink( 'ru' );
+ $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens FR', '', 'fr' ) );
+ $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Dragons RU', '', 'ru' ) );
+ $b->addExternalLink( 'https://dragons.wikimedia.test' );
+ $b->addExternalLink( 'https://goats.wikimedia.test' );
+
+ $b->addCategory( 'Bar', 'Y' );
+ $b->addImage( 'Puff.jpg', '20180101000017', 'BEEF' );
+
+ yield 'all kinds of links' => [ $a, $b, [
+ 'getLinks' => [
+ NS_MAIN => [
+ 'Kittens' => 6,
+ 'Goats' => 7,
+ 'Dragons' => 8,
+ ],
+ NS_TALK => [
+ 'Kittens' => 16,
+ 'Goats' => 17,
+ ],
+ NS_FILE => [
+ 'Dragons.jpg' => 28,
+ ],
+ ],
+ 'getTemplates' => [
+ NS_MAIN => [
+ 'Dragons' => 118,
+ ],
+ NS_TEMPLATE => [
+ 'Dragons' => 108,
+ 'Goats' => 107,
+ ],
+ ],
+ 'getTemplateIds' => [
+ NS_MAIN => [
+ 'Dragons' => 1118,
+ ],
+ NS_TEMPLATE => [
+ 'Dragons' => 1108,
+ 'Goats' => 1107,
+ ],
+ ],
+ 'getLanguageLinks' => [ 'de', 'ru', 'fr' ],
+ 'getInterwikiLinks' => [
+ 'de' => [ 'Kittens_DE' => 1 ],
+ 'ru' => [ 'Kittens_RU' => 1, 'Dragons_RU' => 1, ],
+ 'fr' => [ 'Kittens_FR' => 1 ],
+ ],
+ 'getCategories' => [ 'Foo' => 'X', 'Bar' => 'Y' ],
+ 'getImages' => [ 'Billy.jpg' => 1, 'Puff.jpg' => 1 ],
+ 'getFileSearchOptions' => [
+ 'Billy.jpg' => [ 'time' => '20180101000013', 'sha1' => 'DEAD' ],
+ 'Puff.jpg' => [ 'time' => '20180101000017', 'sha1' => 'BEEF' ],
+ ],
+ 'getExternalLinks' => [
+ 'https://dragons.wikimedia.test' => 1,
+ 'https://kittens.wikimedia.test' => 1,
+ 'https://goats.wikimedia.test' => 1,
+ ]
+ ] ];
+
+ // properties ------------
+ $a = new ParserOutput();
+
+ $a->setProperty( 'foo', 'Foo!' );
+ $a->setProperty( 'bar', 'Bar!' );
+
+ $a->setExtensionData( 'foo', 'Foo!' );
+ $a->setExtensionData( 'bar', 'Bar!' );
+
+ $b = new ParserOutput();
+
+ $b->setProperty( 'zoo', 'Zoo!' );
+ $b->setProperty( 'bar', 'Barrr!' );
+
+ $b->setExtensionData( 'zoo', 'Zoo!' );
+ $b->setExtensionData( 'bar', 'Barrr!' );
+
+ yield 'properties' => [ $a, $b, [
+ 'getProperties' => [
+ 'foo' => 'Foo!',
+ 'bar' => 'Barrr!',
+ 'zoo' => 'Zoo!',
+ ],
+ '$mExtensionData' => [
+ 'foo' => 'Foo!',
+ 'bar' => 'Barrr!',
+ 'zoo' => 'Zoo!',
+ ],
+ ] ];
+ }
+
+ /**
+ * @dataProvider provideMergeTrackingMetaDataFrom
+ * @covers ParserOutput::mergeTrackingMetaDataFrom
+ *
+ * @param ParserOutput $a
+ * @param ParserOutput $b
+ * @param array $expected
+ */
+ public function testMergeTrackingMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+ $a->mergeTrackingMetaDataFrom( $b );
+
+ $this->assertFieldValues( $a, $expected );
+
+ // test twice, to make sure the operation is idempotent
+ $a->mergeTrackingMetaDataFrom( $b );
+
+ $this->assertFieldValues( $a, $expected );
+ }
+
+ public function provideMergeInternalMetaDataFrom() {
+ // hooks
+ $a = new ParserOutput();
+
+ $a->addOutputHook( 'foo', 'X' );
+ $a->addOutputHook( 'bar' );
+
+ $b = new ParserOutput();
+
+ $b->addOutputHook( 'foo', 'Y' );
+ $b->addOutputHook( 'bar' );
+ $b->addOutputHook( 'zoo' );
+
+ yield 'hooks' => [ $a, $b, [
+ 'getOutputHooks' => [
+ [ 'foo', 'X' ],
+ [ 'bar', false ],
+ [ 'foo', 'Y' ],
+ [ 'zoo', false ],
+ ],
+ ] ];
+
+ // flags & co
+ $a = new ParserOutput();
+
+ $a->addWarning( 'Oops' );
+ $a->addWarning( 'Whoops' );
+
+ $a->setFlag( 'foo' );
+ $a->setFlag( 'bar' );
+
+ $a->recordOption( 'Foo' );
+ $a->recordOption( 'Bar' );
+
+ $b = new ParserOutput();
+
+ $b->addWarning( 'Yikes' );
+ $b->addWarning( 'Whoops' );
+
+ $b->setFlag( 'zoo' );
+ $b->setFlag( 'bar' );
+
+ $b->recordOption( 'Zoo' );
+ $b->recordOption( 'Bar' );
+
+ yield 'flags' => [ $a, $b, [
+ 'getWarnings' => [ 'Oops', 'Whoops', 'Yikes' ],
+ '$mFlags' => [ 'foo' => true, 'bar' => true, 'zoo' => true ],
+ 'getUsedOptions' => [ 'Foo', 'Bar', 'Zoo' ],
+ ] ];
+
+ // timestamp ------------
+ $a = new ParserOutput();
+ $a->setTimestamp( '20180101000011' );
+ $b = new ParserOutput();
+ yield 'only left timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+ $a = new ParserOutput();
+ $b = new ParserOutput();
+ $b->setTimestamp( '20180101000011' );
+ yield 'only right timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+ $a = new ParserOutput();
+ $a->setTimestamp( '20180101000011' );
+ $b = new ParserOutput();
+ $b->setTimestamp( '20180101000001' );
+ yield 'left timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+ $a = new ParserOutput();
+ $a->setTimestamp( '20180101000001' );
+ $b = new ParserOutput();
+ $b->setTimestamp( '20180101000011' );
+ yield 'right timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+ // speculative rev id ------------
+ $a = new ParserOutput();
+ $a->setSpeculativeRevIdUsed( 9 );
+ $b = new ParserOutput();
+ yield 'only left speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+ $a = new ParserOutput();
+ $b = new ParserOutput();
+ $b->setSpeculativeRevIdUsed( 9 );
+ yield 'only right speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+ $a = new ParserOutput();
+ $a->setSpeculativeRevIdUsed( 9 );
+ $b = new ParserOutput();
+ $b->setSpeculativeRevIdUsed( 9 );
+ yield 'same speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+ // limit report (recursive max) ------------
+ $a = new ParserOutput();
+
+ $a->setLimitReportData( 'naive1', 7 );
+ $a->setLimitReportData( 'naive2', 27 );
+
+ $a->setLimitReportData( 'limitreport-simple1', 7 );
+ $a->setLimitReportData( 'limitreport-simple2', 27 );
+
+ $a->setLimitReportData( 'limitreport-pair1', [ 7, 9 ] );
+ $a->setLimitReportData( 'limitreport-pair2', [ 27, 29 ] );
+
+ $a->setLimitReportData( 'limitreport-more1', [ 7, 9, 1 ] );
+ $a->setLimitReportData( 'limitreport-more2', [ 27, 29, 21 ] );
+
+ $a->setLimitReportData( 'limitreport-only-a', 13 );
+
+ $b = new ParserOutput();
+
+ $b->setLimitReportData( 'naive1', 17 );
+ $b->setLimitReportData( 'naive2', 17 );
+
+ $b->setLimitReportData( 'limitreport-simple1', 17 );
+ $b->setLimitReportData( 'limitreport-simple2', 17 );
+
+ $b->setLimitReportData( 'limitreport-pair1', [ 17, 19 ] );
+ $b->setLimitReportData( 'limitreport-pair2', [ 17, 19 ] );
+
+ $b->setLimitReportData( 'limitreport-more1', [ 17, 19, 11 ] );
+ $b->setLimitReportData( 'limitreport-more2', [ 17, 19, 11 ] );
+
+ $b->setLimitReportData( 'limitreport-only-b', 23 );
+
+ // first write wins
+ yield 'limit report' => [ $a, $b, [
+ 'getLimitReportData' => [
+ 'naive1' => 7,
+ 'naive2' => 27,
+ 'limitreport-simple1' => 7,
+ 'limitreport-simple2' => 27,
+ 'limitreport-pair1' => [ 7, 9 ],
+ 'limitreport-pair2' => [ 27, 29 ],
+ 'limitreport-more1' => [ 7, 9, 1 ],
+ 'limitreport-more2' => [ 27, 29, 21 ],
+ 'limitreport-only-a' => 13,
+ ],
+ 'getLimitReportJSData' => [
+ 'naive1' => 7,
+ 'naive2' => 27,
+ 'limitreport' => [
+ 'simple1' => 7,
+ 'simple2' => 27,
+ 'pair1' => [ 'value' => 7, 'limit' => 9 ],
+ 'pair2' => [ 'value' => 27, 'limit' => 29 ],
+ 'more1' => [ 7, 9, 1 ],
+ 'more2' => [ 27, 29, 21 ],
+ 'only-a' => 13,
+ ],
+ ],
+ ] ];
+ }
+
+ /**
+ * @dataProvider provideMergeInternalMetaDataFrom
+ * @covers ParserOutput::mergeInternalMetaDataFrom
+ *
+ * @param ParserOutput $a
+ * @param ParserOutput $b
+ * @param array $expected
+ */
+ public function testMergeInternalMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+ $a->mergeInternalMetaDataFrom( $b );
+
+ $this->assertFieldValues( $a, $expected );
+
+ // test twice, to make sure the operation is idempotent
+ $a->mergeInternalMetaDataFrom( $b );
+
+ $this->assertFieldValues( $a, $expected );
+ }
+
+ public function testMergeInternalMetaDataFrom_parseStartTime() {
+ /** @var object $a */
+ $a = new ParserOutput();
+ $a = TestingAccessWrapper::newFromObject( $a );
+
+ $a->resetParseStartTime();
+ $aClocks = $a->mParseStartTime;
+
+ $b = new ParserOutput();
+
+ $a->mergeInternalMetaDataFrom( $b );
+ $mergedClocks = $a->mParseStartTime;
+
+ foreach ( $mergedClocks as $clock => $timestamp ) {
+ $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+ }
+
+ // try again, with times in $b also set, and later than $a's
+ usleep( 1234 );
+
+ /** @var object $b */
+ $b = new ParserOutput();
+ $b = TestingAccessWrapper::newFromObject( $b );
+
+ $b->resetParseStartTime();
+
+ $bClocks = $b->mParseStartTime;
+
+ $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+ $mergedClocks = $a->mParseStartTime;
+
+ foreach ( $mergedClocks as $clock => $timestamp ) {
+ $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+ $this->assertLessThanOrEqual( $bClocks[$clock], $timestamp, $clock );
+ }
+
+ // try again, with $a's times being later
+ usleep( 1234 );
+ $a->resetParseStartTime();
+ $aClocks = $a->mParseStartTime;
+
+ $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+ $mergedClocks = $a->mParseStartTime;
+
+ foreach ( $mergedClocks as $clock => $timestamp ) {
+ $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+ $this->assertLessThanOrEqual( $aClocks[$clock], $timestamp, $clock );
+ }
+
+ // try again, with no times in $a set
+ $a = new ParserOutput();
+ $a = TestingAccessWrapper::newFromObject( $a );
+
+ $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+ $mergedClocks = $a->mParseStartTime;
+
+ foreach ( $mergedClocks as $clock => $timestamp ) {
+ $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+ }
+ }
+
}