--- /dev/null
+<?php
+/**
+ * Temporary action for MCR undos
+ * @file
+ * @ingroup Actions
+ */
+
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+
+/**
+ * Temporary action for MCR undos
+ *
+ * This is intended to go away when real MCR support is added to EditPage and
+ * the standard undo-with-edit behavior can be implemented there instead.
+ *
+ * If this were going to be kept, we'd probably want to figure out a good way
+ * to reuse the same code for generating the headers, summary box, and buttons
+ * on EditPage and here, and to better share the diffing and preview logic
+ * between the two. But doing that now would require much of the rewriting of
+ * EditPage that we're trying to put off by doing this instead.
+ *
+ * @ingroup Actions
+ * @since 1.32
+ * @deprecated since 1.32
+ */
+class McrUndoAction extends FormAction {
+
+ private $undo = 0, $undoafter = 0, $cur = 0;
+
+ /** @param RevisionRecord|null */
+ private $curRev = null;
+
+ public function getName() {
+ return 'mcrundo';
+ }
+
+ public function getDescription() {
+ return '';
+ }
+
+ public function show() {
+ // Send a cookie so anons get talk message notifications
+ // (copied from SubmitAction)
+ MediaWiki\Session\SessionManager::getGlobalSession()->persist();
+
+ // Some stuff copied from EditAction
+ $this->useTransactionalTimeLimit();
+
+ $out = $this->getOutput();
+ $out->setRobotPolicy( 'noindex,nofollow' );
+ if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+
+ // IP warning headers copied from EditPage
+ // (should more be copied?)
+ if ( wfReadOnly() ) {
+ $out->wrapWikiMsg(
+ "<div id=\"mw-read-only-warning\">\n$1\n</div>",
+ [ 'readonlywarning', wfReadOnlyReason() ]
+ );
+ } elseif ( $this->context->getUser()->isAnon() ) {
+ if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
+ $out->wrapWikiMsg(
+ "<div id='mw-anon-edit-warning' class='warningbox'>\n$1\n</div>",
+ [ 'anoneditwarning',
+ // Log-in link
+ SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
+ 'returnto' => $this->getTitle()->getPrefixedDBkey()
+ ] ),
+ // Sign-up link
+ SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
+ 'returnto' => $this->getTitle()->getPrefixedDBkey()
+ ] )
+ ]
+ );
+ } else {
+ $out->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
+ 'anonpreviewwarning'
+ );
+ }
+ }
+
+ parent::show();
+ }
+
+ protected function checkCanExecute( User $user ) {
+ parent::checkCanExecute( $user );
+
+ $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
+ $this->undo = $this->getRequest()->getInt( 'undo' );
+
+ if ( $this->undo == 0 || $this->undoafter == 0 ) {
+ throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
+ }
+
+ $curRev = $this->page->getRevision();
+ if ( !$curRev ) {
+ throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
+ }
+ $this->curRev = $curRev->getRevisionRecord();
+ $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
+
+ $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
+
+ $undoRev = $revisionLookup->getRevisionById( $this->undo );
+ $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
+
+ if ( $undoRev === null || $oldRev === null ||
+ $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
+ $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
+ ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
+ }
+
+ return true;
+ }
+
+ /**
+ * @return MutableRevisionRecord
+ */
+ private function getNewRevision() {
+ $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
+
+ $undoRev = $revisionLookup->getRevisionById( $this->undo );
+ $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
+ $curRev = $this->curRev;
+
+ $isLatest = $curRev->getId() === $undoRev->getId();
+
+ if ( $undoRev === null || $oldRev === null ||
+ $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
+ $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
+ ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
+ }
+
+ if ( $isLatest ) {
+ // Short cut! Undoing the current revision means we just restore the old.
+ return MutableRevisionRecord::newFromParentRevision( $oldRev );
+ }
+
+ $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
+
+ // Figure out the roles that need merging by first collecting all roles
+ // and then removing the ones that don't.
+ $rolesToMerge = array_unique( array_merge(
+ $oldRev->getSlotRoles(),
+ $undoRev->getSlotRoles(),
+ $curRev->getSlotRoles()
+ ) );
+
+ // Any roles with the same content in $oldRev and $undoRev can be
+ // inherited because undo won't change them.
+ $rolesToMerge = array_intersect(
+ $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
+ );
+ if ( !$rolesToMerge ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
+ }
+
+ // Any roles with the same content in $oldRev and $curRev were already reverted
+ // and so can be inherited.
+ $rolesToMerge = array_intersect(
+ $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
+ );
+ if ( !$rolesToMerge ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
+ }
+
+ // Any roles with the same content in $undoRev and $curRev weren't
+ // changed since and so can be reverted to $oldRev.
+ $diffRoles = array_intersect(
+ $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
+ );
+ foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
+ if ( $oldRev->hasSlot( $role ) ) {
+ $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
+ } else {
+ $newRev->removeSlot( $role );
+ }
+ }
+ $rolesToMerge = $diffRoles;
+
+ // Any slot additions or removals not handled by the above checks can't be undone.
+ // There will be only one of the three revisions missing the slot:
+ // - !old means it was added in the undone revisions and modified after.
+ // Should it be removed entirely for the undo, or should the modified version be kept?
+ // - !undo means it was removed in the undone revisions and then readded with different content.
+ // Which content is should be kept, the old or the new?
+ // - !cur means it was changed in the undone revisions and then deleted after.
+ // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
+ // it), or should it stay gone?
+ foreach ( $rolesToMerge as $role ) {
+ if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
+ }
+ }
+
+ // Try to merge anything that's left.
+ foreach ( $rolesToMerge as $role ) {
+ $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
+ $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
+ $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
+ $newContent = $undoContent->getContentHandler()
+ ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
+ if ( !$newContent ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
+ }
+ $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
+ }
+
+ return $newRev;
+ }
+
+ private function generateDiff() {
+ $newRev = $this->getNewRevision();
+ if ( $newRev->hasSameContent( $this->curRev ) ) {
+ throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
+ }
+
+ $diffEngine = new DifferenceEngine( $this->context );
+ $diffEngine->setRevisions( $this->curRev, $newRev );
+
+ $oldtitle = $this->context->msg( 'currentrev' )->parse();
+ $newtitle = $this->context->msg( 'yourtext' )->parse();
+
+ if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
+ $diffEngine->renderNewRevision();
+ return '';
+ } else {
+ $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
+ $diffEngine->showDiffStyle();
+ return '<div id="wikiDiff">' . $diffText . '</div>';
+ }
+ }
+
+ public function onSubmit( $data ) {
+ global $wgUseRCPatrol;
+
+ if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
+ // Diff or preview
+ return false;
+ }
+
+ $updater = $this->page->getPage()->newPageUpdater( $this->context->getUser() );
+ $curRev = $updater->grabParentRevision();
+ if ( !$curRev ) {
+ throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
+ }
+
+ if ( $this->cur !== $curRev->getId() ) {
+ return Status::newFatal( 'mcrundo-changed' );
+ }
+
+ $newRev = $this->getNewRevision();
+ if ( !$newRev->hasSameContent( $curRev ) ) {
+ // Copy new slots into the PageUpdater, and remove any removed slots.
+ // TODO: This interface is awful, there should be a way to just pass $newRev.
+ // TODO: MCR: test this once we can store multiple slots
+ foreach ( $newRev->getSlots()->getSlots() as $slot ) {
+ $updater->setSlot( $slot );
+ }
+ foreach ( $curRev->getSlotRoles() as $role ) {
+ if ( !$newRev->hasSlot( $role ) ) {
+ $updater->removeSlot( $role );
+ }
+ }
+
+ $updater->setOriginalRevisionId( false );
+ $updater->setUndidRevisionId( $this->undo );
+
+ // TODO: Ugh.
+ if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $this->getUser() ) ) {
+ $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
+ }
+
+ $updater->saveRevision(
+ CommentStoreComment::newUnsavedComment( trim( $this->getRequest()->getVal( 'wpSummary' ) ) ),
+ EDIT_AUTOSUMMARY | EDIT_UPDATE
+ );
+
+ return $updater->getStatus();
+ }
+
+ return Status::newGood();
+ }
+
+ protected function usesOOUI() {
+ return true;
+ }
+
+ protected function getFormFields() {
+ $request = $this->getRequest();
+ $config = $this->context->getConfig();
+ $oldCommentSchema = $config->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD;
+ $ret = [
+ 'diff' => [
+ 'type' => 'info',
+ 'vertical-label' => true,
+ 'raw' => true,
+ 'default' => function () {
+ return $this->generateDiff();
+ }
+ ],
+ 'summary' => [
+ 'type' => 'text',
+ 'id' => 'wpSummary',
+ 'name' => 'wpSummary',
+ 'cssclass' => 'mw-summary',
+ 'label-message' => 'summary',
+ 'maxlength' => $oldCommentSchema ? 200 : CommentStore::COMMENT_CHARACTER_LIMIT,
+ 'value' => $request->getVal( 'wpSummary', '' ),
+ 'size' => 60,
+ 'spellcheck' => 'true',
+ ],
+ 'summarypreview' => [
+ 'type' => 'info',
+ 'label-message' => 'summary-preview',
+ 'raw' => true,
+ ],
+ ];
+
+ if ( $request->getCheck( 'wpSummary' ) ) {
+ $ret['summarypreview']['default'] = Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ],
+ Linker::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
+ );
+ } else {
+ unset( $ret['summarypreview'] );
+ }
+
+ return $ret;
+ }
+
+ protected function alterForm( HTMLForm $form ) {
+ $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
+
+ $labelAsPublish = $this->context->getConfig()->get( 'EditSubmitButtonLabelPublish' );
+
+ $form->setSubmitName( 'wpSave' );
+ $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
+ $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
+ $form->showCancel( true );
+ $form->setCancelTarget( $this->getTitle() );
+ $form->addButton( [
+ 'name' => 'wpPreview',
+ 'value' => '1',
+ 'label-message' => 'showpreview',
+ 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
+ ] );
+ $form->addButton( [
+ 'name' => 'wpDiff',
+ 'value' => '1',
+ 'label-message' => 'showdiff',
+ 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
+ ] );
+
+ $form->addHiddenField( 'undo', $this->undo );
+ $form->addHiddenField( 'undoafter', $this->undoafter );
+ $form->addHiddenField( 'cur', $this->curRev->getId() );
+ }
+
+ public function onSuccess() {
+ $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
+ }
+
+ protected function preText() {
+ return '<div style="clear:both"></div>';
+ }
+}
<?php
-
-use MediaWiki\Logger\LoggerFactory;
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Search\ParserOutputSearchDataExtractor;
-
/**
* Base class for content handling.
*
*
* @author Daniel Kinzler
*/
+
+use Wikimedia\Assert\Assert;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Search\ParserOutputSearchDataExtractor;
+
/**
* A content handler knows how do deal with a specific type of content on a wiki
* page. Content is stored in the database in a serialized form (using a
* must exist and must not be deleted.
*
* @since 1.21
+ * @since 1.32 accepts Content objects for all parameters instead of Revision objects.
+ * Passing Revision objects is deprecated.
*
- * @param Revision $current The current text
- * @param Revision $undo The revision to undo
- * @param Revision $undoafter Must be an earlier revision than $undo
+ * @param Revision|Content $current The current text
+ * @param Revision|Content $undo The content of the revision to undo
+ * @param Revision|Content $undoafter Must be from an earlier revision than $undo
+ * @param bool $undoIsLatest Set true if $undo is from the current revision (since 1.32)
*
* @return mixed Content on success, false on failure
*/
- public function getUndoContent( Revision $current, Revision $undo, Revision $undoafter ) {
- $cur_content = $current->getContent();
+ public function getUndoContent( $current, $undo, $undoafter, $undoIsLatest = false ) {
+ Assert::parameterType( Revision::class . '|' . Content::class, $current, '$current' );
+ if ( $current instanceof Content ) {
+ Assert::parameter( $undo instanceof Content, '$undo',
+ 'Must be Content when $current is Content' );
+ Assert::parameter( $undoafter instanceof Content, '$undoafter',
+ 'Must be Content when $current is Content' );
+ $cur_content = $current;
+ $undo_content = $undo;
+ $undoafter_content = $undoafter;
+ } else {
+ Assert::parameter( $undo instanceof Revision, '$undo',
+ 'Must be Revision when $current is Revision' );
+ Assert::parameter( $undoafter instanceof Revision, '$undoafter',
+ 'Must be Revision when $current is Revision' );
- if ( empty( $cur_content ) ) {
- return false; // no page
- }
+ $cur_content = $current->getContent();
- $undo_content = $undo->getContent();
- $undoafter_content = $undoafter->getContent();
+ if ( empty( $cur_content ) ) {
+ return false; // no page
+ }
+
+ $undo_content = $undo->getContent();
+ $undoafter_content = $undoafter->getContent();
+
+ if ( !$undo_content || !$undoafter_content ) {
+ return false; // no content to undo
+ }
- if ( !$undo_content || !$undoafter_content ) {
- return false; // no content to undo
+ $undoIsLatest = $current->getId() === $undo->getId();
}
try {
$this->checkModelID( $cur_content->getModel() );
$this->checkModelID( $undo_content->getModel() );
- if ( $current->getId() !== $undo->getId() ) {
+ if ( !$undoIsLatest ) {
// If we are undoing the most recent revision,
// its ok to revert content model changes. However
// if we are undoing a revision in the middle, then