'mw-undo', EditResult::REVERT_ROLLBACK => 'mw-rollback', EditResult::REVERT_MANUAL => 'mw-manual-revert' ]; /** @var RevisionRecord|null */ private $revisionRecord = null; /** @var bool */ private $isNew = false; /** @var int|bool */ private $originalRevisionId = false; /** @var RevisionRecord|null */ private $originalRevision = null; /** @var int|null */ private $revertMethod = null; /** @var int|null */ private $newestRevertedRevId = null; /** @var int|null */ private $oldestRevertedRevId = null; /** @var int|null */ private $revertAfterRevId = null; /** @var RevisionStore */ private $revisionStore; /** @var string[] */ private $softwareTags; /** @var ServiceOptions */ private $options; /** * @param RevisionStore $revisionStore * @param string[] $softwareTags Array of currently enabled software change tags. Can be * obtained from ChangeTags::getSoftwareTags() * @param ServiceOptions $options Options for this instance. */ public function __construct( RevisionStore $revisionStore, array $softwareTags, ServiceOptions $options ) { $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); $this->revisionStore = $revisionStore; $this->softwareTags = $softwareTags; $this->options = $options; } /** * @return EditResult */ public function buildEditResult(): EditResult { if ( $this->revisionRecord === null ) { throw new PageUpdateException( 'Revision was not set prior to building an EditResult' ); } // If we don't know the original revision ID, but know which one was undone, try to find out $this->guessOriginalRevisionId(); // do a last-minute check if this was a manual revert $this->detectManualRevert(); return new EditResult( $this->isNew, $this->originalRevisionId, $this->revertMethod, $this->oldestRevertedRevId, $this->newestRevertedRevId, $this->isExactRevert(), $this->isNullEdit(), $this->getRevertTags() ); } /** * Set the revision associated with this edit. * Should only be called by PageUpdater when saving an edit. * * @param RevisionRecord $revisionRecord */ public function setRevisionRecord( RevisionRecord $revisionRecord ) { $this->revisionRecord = $revisionRecord; } /** * Set whether the edit created a new page. * Should only be called by PageUpdater when saving an edit. * * @param bool $isNew */ public function setIsNew( bool $isNew ) { $this->isNew = $isNew; } /** * Marks this edit as a revert and applies relevant information. * * @param int $revertMethod The method used to make the revert: * REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL * @param int $newestRevertedRevId the revision ID of the latest reverted revision. * @param int|null $revertAfterRevId the revision ID after which revisions * are being reverted. Defaults to the revision before the $newestRevertedRevId. */ public function markAsRevert( int $revertMethod, int $newestRevertedRevId, int $revertAfterRevId = null ) { Assert::parameter( in_array( $revertMethod, [ EditResult::REVERT_UNDO, EditResult::REVERT_ROLLBACK, EditResult::REVERT_MANUAL ] ), '$revertMethod', 'must be one of REVERT_UNDO, REVERT_ROLLBACK, REVERT_MANUAL' ); $this->revertAfterRevId = $revertAfterRevId; if ( $newestRevertedRevId ) { $this->revertMethod = $revertMethod; $this->newestRevertedRevId = $newestRevertedRevId; $revertAfterRevision = $revertAfterRevId ? $this->revisionStore->getRevisionById( $revertAfterRevId ) : null; $oldestRevertedRev = $revertAfterRevision ? $this->revisionStore->getNextRevision( $revertAfterRevision ) : null; if ( $oldestRevertedRev ) { $this->oldestRevertedRevId = $oldestRevertedRev->getId(); } else { // Can't find the oldest reverted revision. // Oh well, just mark the one we know was undone. $this->oldestRevertedRevId = $this->newestRevertedRevId; } } } /** * @param RevisionRecord|int|bool|null $originalRevision * RevisionRecord or revision ID for the original revision. * False or null to unset. */ public function setOriginalRevision( $originalRevision ) { if ( $originalRevision instanceof RevisionRecord ) { $this->originalRevision = $originalRevision; $this->originalRevisionId = $originalRevision->getId(); } else { $this->originalRevisionId = $originalRevision ?? false; $this->originalRevision = null; // Will be lazy-loaded. } } /** * If this edit was not already marked as a revert using EditResultBuilder::markAsRevert(), * tries to establish whether this was a manual revert, i.e. someone restored the page to * an exact previous state manually. * * If successful, mutates the builder accordingly. */ private function detectManualRevert() { $searchRadius = $this->options->get( MainConfigNames::ManualRevertSearchRadius ); if ( !$searchRadius || // we already marked this as a revert $this->revertMethod !== null || // it's a null edit, nothing was reverted $this->isNullEdit() || // we wouldn't be able to figure out what was the newest reverted edit // this also discards new pages !$this->revisionRecord->getParentId() ) { return; } $revertedToRev = $this->revisionStore->findIdenticalRevision( $this->revisionRecord, $searchRadius ); if ( !$revertedToRev ) { return; } $oldestReverted = $this->revisionStore->getNextRevision( $revertedToRev ); if ( !$oldestReverted ) { return; } $this->setOriginalRevision( $revertedToRev ); $this->revertMethod = EditResult::REVERT_MANUAL; $this->oldestRevertedRevId = $oldestReverted->getId(); $this->newestRevertedRevId = $this->revisionRecord->getParentId(); $this->revertAfterRevId = $revertedToRev->getId(); } /** * In case we have not got the original revision ID, try to guess. */ private function guessOriginalRevisionId() { if ( !$this->originalRevisionId ) { if ( $this->revertAfterRevId ) { $this->setOriginalRevision( $this->revertAfterRevId ); } elseif ( $this->newestRevertedRevId ) { // Try finding the original revision ID by assuming it's the one before the edit // that is being reverted. $undidRevision = $this->revisionStore->getRevisionById( $this->newestRevertedRevId ); if ( $undidRevision ) { $originalRevision = $this->revisionStore->getPreviousRevision( $undidRevision ); if ( $originalRevision ) { $this->setOriginalRevision( $originalRevision ); } } } } // Make sure original revision's content is the same as // the new content and save the original revision ID. if ( $this->getOriginalRevision() && !$this->getOriginalRevision()->hasSameContent( $this->revisionRecord ) ) { $this->setOriginalRevision( false ); } } /** * Returns the revision that is being repeated or restored. * Returns null if not set for this edit. * * @return RevisionRecord|null */ private function getOriginalRevision(): ?RevisionRecord { if ( $this->originalRevision ) { return $this->originalRevision; } if ( !$this->originalRevisionId ) { return null; } $this->originalRevision = $this->revisionStore->getRevisionById( $this->originalRevisionId ); return $this->originalRevision; } /** * Whether the edit was an exact revert, i.e. the contents of the revert * revision and restored revision match * * @return bool */ private function isExactRevert(): bool { if ( $this->isNew || $this->oldestRevertedRevId === null ) { return false; } $originalRevision = $this->getOriginalRevision(); if ( !$originalRevision ) { // we can't find the original revision for some reason, better return false return false; } return $this->revisionRecord->hasSameContent( $originalRevision ); } /** * An edit is a null edit if the original revision is equal to the parent revision. * * @return bool */ private function isNullEdit(): bool { if ( $this->isNew ) { return false; } return $this->getOriginalRevision() && $this->originalRevisionId === $this->revisionRecord->getParentId(); } /** * Returns an array of revert-related tags that will be applied automatically to this edit. * * @return string[] */ private function getRevertTags(): array { if ( isset( self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod] ) ) { $revertTag = self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod]; if ( in_array( $revertTag, $this->softwareTags ) ) { return [ $revertTag ]; } } return []; } }