3 * Temporary action for MCR undos
8 use MediaWiki\MediaWikiServices
;
9 use MediaWiki\Storage\MutableRevisionRecord
;
10 use MediaWiki\Storage\RevisionRecord
;
11 use MediaWiki\Storage\SlotRecord
;
14 * Temporary action for MCR undos
16 * This is intended to go away when real MCR support is added to EditPage and
17 * the standard undo-with-edit behavior can be implemented there instead.
19 * If this were going to be kept, we'd probably want to figure out a good way
20 * to reuse the same code for generating the headers, summary box, and buttons
21 * on EditPage and here, and to better share the diffing and preview logic
22 * between the two. But doing that now would require much of the rewriting of
23 * EditPage that we're trying to put off by doing this instead.
27 * @deprecated since 1.32
29 class McrUndoAction
extends FormAction
{
31 private $undo = 0, $undoafter = 0, $cur = 0;
33 /** @param RevisionRecord|null */
34 private $curRev = null;
36 public function getName() {
40 public function getDescription() {
44 public function show() {
45 // Send a cookie so anons get talk message notifications
46 // (copied from SubmitAction)
47 MediaWiki\Session\SessionManager
::getGlobalSession()->persist();
49 // Some stuff copied from EditAction
50 $this->useTransactionalTimeLimit();
52 $out = $this->getOutput();
53 $out->setRobotPolicy( 'noindex,nofollow' );
54 if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
55 $out->addModuleStyles( [
57 'mediawiki.ui.checkbox',
61 // IP warning headers copied from EditPage
62 // (should more be copied?)
65 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
66 [ 'readonlywarning', wfReadOnlyReason() ]
68 } elseif ( $this->context
->getUser()->isAnon() ) {
69 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
71 "<div id='mw-anon-edit-warning' class='warningbox'>\n$1\n</div>",
74 SpecialPage
::getTitleFor( 'Userlogin' )->getFullURL( [
75 'returnto' => $this->getTitle()->getPrefixedDBkey()
78 SpecialPage
::getTitleFor( 'CreateAccount' )->getFullURL( [
79 'returnto' => $this->getTitle()->getPrefixedDBkey()
84 $out->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
93 protected function checkCanExecute( User
$user ) {
94 parent
::checkCanExecute( $user );
96 $this->undoafter
= $this->getRequest()->getInt( 'undoafter' );
97 $this->undo
= $this->getRequest()->getInt( 'undo' );
99 if ( $this->undo
== 0 ||
$this->undoafter
== 0 ) {
100 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
103 $curRev = $this->page
->getRevision();
105 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
107 $this->curRev
= $curRev->getRevisionRecord();
108 $this->cur
= $this->getRequest()->getInt( 'cur', $this->curRev
->getId() );
110 $revisionLookup = MediaWikiServices
::getInstance()->getRevisionLookup();
112 $undoRev = $revisionLookup->getRevisionById( $this->undo
);
113 $oldRev = $revisionLookup->getRevisionById( $this->undoafter
);
115 if ( $undoRev === null ||
$oldRev === null ||
116 $undoRev->isDeleted( RevisionRecord
::DELETED_TEXT
) ||
117 $oldRev->isDeleted( RevisionRecord
::DELETED_TEXT
)
119 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
126 * @return MutableRevisionRecord
128 private function getNewRevision() {
129 $revisionLookup = MediaWikiServices
::getInstance()->getRevisionLookup();
131 $undoRev = $revisionLookup->getRevisionById( $this->undo
);
132 $oldRev = $revisionLookup->getRevisionById( $this->undoafter
);
133 $curRev = $this->curRev
;
135 $isLatest = $curRev->getId() === $undoRev->getId();
137 if ( $undoRev === null ||
$oldRev === null ||
138 $undoRev->isDeleted( RevisionRecord
::DELETED_TEXT
) ||
139 $oldRev->isDeleted( RevisionRecord
::DELETED_TEXT
)
141 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
145 // Short cut! Undoing the current revision means we just restore the old.
146 return MutableRevisionRecord
::newFromParentRevision( $oldRev );
149 $newRev = MutableRevisionRecord
::newFromParentRevision( $curRev );
151 // Figure out the roles that need merging by first collecting all roles
152 // and then removing the ones that don't.
153 $rolesToMerge = array_unique( array_merge(
154 $oldRev->getSlotRoles(),
155 $undoRev->getSlotRoles(),
156 $curRev->getSlotRoles()
159 // Any roles with the same content in $oldRev and $undoRev can be
160 // inherited because undo won't change them.
161 $rolesToMerge = array_intersect(
162 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
164 if ( !$rolesToMerge ) {
165 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
168 // Any roles with the same content in $oldRev and $curRev were already reverted
169 // and so can be inherited.
170 $rolesToMerge = array_intersect(
171 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
173 if ( !$rolesToMerge ) {
174 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
177 // Any roles with the same content in $undoRev and $curRev weren't
178 // changed since and so can be reverted to $oldRev.
179 $diffRoles = array_intersect(
180 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
182 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
183 if ( $oldRev->hasSlot( $role ) ) {
184 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord
::RAW
) );
186 $newRev->removeSlot( $role );
189 $rolesToMerge = $diffRoles;
191 // Any slot additions or removals not handled by the above checks can't be undone.
192 // There will be only one of the three revisions missing the slot:
193 // - !old means it was added in the undone revisions and modified after.
194 // Should it be removed entirely for the undo, or should the modified version be kept?
195 // - !undo means it was removed in the undone revisions and then readded with different content.
196 // Which content is should be kept, the old or the new?
197 // - !cur means it was changed in the undone revisions and then deleted after.
198 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
199 // it), or should it stay gone?
200 foreach ( $rolesToMerge as $role ) {
201 if ( !$oldRev->hasSlot( $role ) ||
!$undoRev->hasSlot( $role ) ||
!$curRev->hasSlot( $role ) ) {
202 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
206 // Try to merge anything that's left.
207 foreach ( $rolesToMerge as $role ) {
208 $oldContent = $oldRev->getSlot( $role, RevisionRecord
::RAW
)->getContent();
209 $undoContent = $undoRev->getSlot( $role, RevisionRecord
::RAW
)->getContent();
210 $curContent = $curRev->getSlot( $role, RevisionRecord
::RAW
)->getContent();
211 $newContent = $undoContent->getContentHandler()
212 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
213 if ( !$newContent ) {
214 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
216 $newRev->setSlot( SlotRecord
::newUnsaved( $role, $newContent ) );
222 private function generateDiffOrPreview() {
223 $newRev = $this->getNewRevision();
224 if ( $newRev->hasSameContent( $this->curRev
) ) {
225 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
228 $diffEngine = new DifferenceEngine( $this->context
);
229 $diffEngine->setRevisions( $this->curRev
, $newRev );
231 $oldtitle = $this->context
->msg( 'currentrev' )->parse();
232 $newtitle = $this->context
->msg( 'yourtext' )->parse();
234 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
235 $this->showPreview( $newRev );
238 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
239 $diffEngine->showDiffStyle();
240 return '<div id="wikiDiff">' . $diffText . '</div>';
244 private function showPreview( RevisionRecord
$rev ) {
245 // Mostly copied from EditPage::getPreviewText()
246 $out = $this->getOutput();
251 # provide a anchor link to the form
252 $continueEditing = '<span class="mw-continue-editing">' .
253 '[[#mw-mcrundo-form|' .
254 $this->context
->getLanguage()->getArrow() . ' ' .
255 $this->context
->msg( 'continue-editing' )->text() . ']]</span>';
257 $note = $this->context
->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
259 $parserOptions = $this->page
->makeParserOptions( $this->context
);
260 $parserOptions->setIsPreview( true );
261 $parserOptions->setIsSectionPreview( false );
262 $parserOptions->enableLimitReport();
264 $parserOutput = MediaWikiServices
::getInstance()->getRevisionRenderer()
265 ->getRenderedRevision( $rev, $parserOptions, $this->context
->getUser() )
266 ->getRevisionParserOutput();
267 $previewHTML = $parserOutput->getText( [ 'enableSectionEditLinks' => false ] );
269 $out->addParserOutputMetadata( $parserOutput );
270 if ( count( $parserOutput->getWarnings() ) ) {
271 $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
273 } catch ( MWContentSerializationException
$ex ) {
274 $m = $this->context
->msg(
275 'content-failed-to-parse',
278 $note .= "\n\n" . $m->parse();
282 $previewhead = "<div class='previewnote'>\n" .
283 '<h2 id="mw-previewheader">' . $this->context
->msg( 'preview' )->escaped() . "</h2>" .
284 $out->parse( $note, true, /* interface */true ) . "<hr /></div>\n";
286 $pageViewLang = $this->getTitle()->getPageViewLanguage();
287 $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
288 'class' => 'mw-content-' . $pageViewLang->getDir() ];
289 $previewHTML = Html
::rawElement( 'div', $attribs, $previewHTML );
291 $out->addHtml( $previewhead . $previewHTML );
294 public function onSubmit( $data ) {
295 global $wgUseRCPatrol;
297 if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
302 $updater = $this->page
->getPage()->newPageUpdater( $this->context
->getUser() );
303 $curRev = $updater->grabParentRevision();
305 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
308 if ( $this->cur
!== $curRev->getId() ) {
309 return Status
::newFatal( 'mcrundo-changed' );
312 $newRev = $this->getNewRevision();
313 if ( !$newRev->hasSameContent( $curRev ) ) {
314 // Copy new slots into the PageUpdater, and remove any removed slots.
315 // TODO: This interface is awful, there should be a way to just pass $newRev.
316 // TODO: MCR: test this once we can store multiple slots
317 foreach ( $newRev->getSlots()->getSlots() as $slot ) {
318 $updater->setSlot( $slot );
320 foreach ( $curRev->getSlotRoles() as $role ) {
321 if ( !$newRev->hasSlot( $role ) ) {
322 $updater->removeSlot( $role );
326 $updater->setOriginalRevisionId( false );
327 $updater->setUndidRevisionId( $this->undo
);
330 if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $this->getUser() ) ) {
331 $updater->setRcPatrolStatus( RecentChange
::PRC_AUTOPATROLLED
);
334 $updater->saveRevision(
335 CommentStoreComment
::newUnsavedComment( trim( $this->getRequest()->getVal( 'wpSummary' ) ) ),
336 EDIT_AUTOSUMMARY | EDIT_UPDATE
339 return $updater->getStatus();
342 return Status
::newGood();
345 protected function usesOOUI() {
349 protected function getFormFields() {
350 $request = $this->getRequest();
351 $config = $this->context
->getConfig();
352 $oldCommentSchema = $config->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD
;
356 'vertical-label' => true,
358 'default' => function () {
359 return $this->generateDiffOrPreview();
365 'name' => 'wpSummary',
366 'cssclass' => 'mw-summary',
367 'label-message' => 'summary',
368 'maxlength' => $oldCommentSchema ?
200 : CommentStore
::COMMENT_CHARACTER_LIMIT
,
369 'value' => $request->getVal( 'wpSummary', '' ),
371 'spellcheck' => 'true',
373 'summarypreview' => [
375 'label-message' => 'summary-preview',
380 if ( $request->getCheck( 'wpSummary' ) ) {
381 $ret['summarypreview']['default'] = Xml
::tags( 'div', [ 'class' => 'mw-summary-preview' ],
382 Linker
::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
385 unset( $ret['summarypreview'] );
391 protected function alterForm( HTMLForm
$form ) {
392 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
394 $labelAsPublish = $this->context
->getConfig()->get( 'EditSubmitButtonLabelPublish' );
396 $form->setId( 'mw-mcrundo-form' );
397 $form->setSubmitName( 'wpSave' );
398 $form->setSubmitTooltip( $labelAsPublish ?
'publish' : 'save' );
399 $form->setSubmitTextMsg( $labelAsPublish ?
'publishchanges' : 'savechanges' );
400 $form->showCancel( true );
401 $form->setCancelTarget( $this->getTitle() );
403 'name' => 'wpPreview',
405 'label-message' => 'showpreview',
406 'attribs' => Linker
::tooltipAndAccesskeyAttribs( 'preview' ),
411 'label-message' => 'showdiff',
412 'attribs' => Linker
::tooltipAndAccesskeyAttribs( 'diff' ),
415 $form->addHiddenField( 'undo', $this->undo
);
416 $form->addHiddenField( 'undoafter', $this->undoafter
);
417 $form->addHiddenField( 'cur', $this->curRev
->getId() );
420 public function onSuccess() {
421 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
424 protected function preText() {
425 return '<div style="clear:both"></div>';