Merge "Type hint against LinkTarget in WatchedItemStore"
[lhc/web/wiklou.git] / includes / actions / McrUndoAction.php
1 <?php
2 /**
3 * Temporary action for MCR undos
4 * @file
5 * @ingroup Actions
6 */
7
8 use MediaWiki\MediaWikiServices;
9 use MediaWiki\Revision\MutableRevisionRecord;
10 use MediaWiki\Revision\RevisionRecord;
11 use MediaWiki\Revision\SlotRecord;
12
13 /**
14 * Temporary action for MCR undos
15 *
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.
18 *
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.
24 *
25 * @ingroup Actions
26 * @since 1.32
27 * @deprecated since 1.32
28 */
29 class McrUndoAction extends FormAction {
30
31 protected $undo = 0, $undoafter = 0, $cur = 0;
32
33 /** @var RevisionRecord|null */
34 protected $curRev = null;
35
36 public function getName() {
37 return 'mcrundo';
38 }
39
40 public function getDescription() {
41 return '';
42 }
43
44 public function show() {
45 // Send a cookie so anons get talk message notifications
46 // (copied from SubmitAction)
47 MediaWiki\Session\SessionManager::getGlobalSession()->persist();
48
49 // Some stuff copied from EditAction
50 $this->useTransactionalTimeLimit();
51
52 $out = $this->getOutput();
53 $out->setRobotPolicy( 'noindex,nofollow' );
54 if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
55 $out->addModuleStyles( [
56 'mediawiki.ui.input',
57 'mediawiki.ui.checkbox',
58 ] );
59 }
60
61 // IP warning headers copied from EditPage
62 // (should more be copied?)
63 if ( wfReadOnly() ) {
64 $out->wrapWikiMsg(
65 "<div id=\"mw-read-only-warning\">\n$1\n</div>",
66 [ 'readonlywarning', wfReadOnlyReason() ]
67 );
68 } elseif ( $this->context->getUser()->isAnon() ) {
69 if ( !$this->getRequest()->getCheck( 'wpPreview' ) ) {
70 $out->wrapWikiMsg(
71 "<div id='mw-anon-edit-warning' class='warningbox'>\n$1\n</div>",
72 [ 'anoneditwarning',
73 // Log-in link
74 SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
75 'returnto' => $this->getTitle()->getPrefixedDBkey()
76 ] ),
77 // Sign-up link
78 SpecialPage::getTitleFor( 'CreateAccount' )->getFullURL( [
79 'returnto' => $this->getTitle()->getPrefixedDBkey()
80 ] )
81 ]
82 );
83 } else {
84 $out->wrapWikiMsg( "<div id=\"mw-anon-preview-warning\" class=\"warningbox\">\n$1</div>",
85 'anonpreviewwarning'
86 );
87 }
88 }
89
90 parent::show();
91 }
92
93 protected function initFromParameters() {
94 $this->undoafter = $this->getRequest()->getInt( 'undoafter' );
95 $this->undo = $this->getRequest()->getInt( 'undo' );
96
97 if ( $this->undo == 0 || $this->undoafter == 0 ) {
98 throw new ErrorPageError( 'mcrundofailed', 'mcrundo-missingparam' );
99 }
100
101 $curRev = $this->page->getRevision();
102 if ( !$curRev ) {
103 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
104 }
105 $this->curRev = $curRev->getRevisionRecord();
106 $this->cur = $this->getRequest()->getInt( 'cur', $this->curRev->getId() );
107 }
108
109 protected function checkCanExecute( User $user ) {
110 parent::checkCanExecute( $user );
111
112 $this->initFromParameters();
113
114 $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
115
116 $undoRev = $revisionLookup->getRevisionById( $this->undo );
117 $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
118
119 if ( $undoRev === null || $oldRev === null ||
120 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
121 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
122 ) {
123 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
124 }
125
126 return true;
127 }
128
129 /**
130 * @return MutableRevisionRecord
131 */
132 private function getNewRevision() {
133 $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
134
135 $undoRev = $revisionLookup->getRevisionById( $this->undo );
136 $oldRev = $revisionLookup->getRevisionById( $this->undoafter );
137 $curRev = $this->curRev;
138
139 $isLatest = $curRev->getId() === $undoRev->getId();
140
141 if ( $undoRev === null || $oldRev === null ||
142 $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
143 $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
144 ) {
145 throw new ErrorPageError( 'mcrundofailed', 'undo-norev' );
146 }
147
148 if ( $isLatest ) {
149 // Short cut! Undoing the current revision means we just restore the old.
150 return MutableRevisionRecord::newFromParentRevision( $oldRev );
151 }
152
153 $newRev = MutableRevisionRecord::newFromParentRevision( $curRev );
154
155 // Figure out the roles that need merging by first collecting all roles
156 // and then removing the ones that don't.
157 $rolesToMerge = array_unique( array_merge(
158 $oldRev->getSlotRoles(),
159 $undoRev->getSlotRoles(),
160 $curRev->getSlotRoles()
161 ) );
162
163 // Any roles with the same content in $oldRev and $undoRev can be
164 // inherited because undo won't change them.
165 $rolesToMerge = array_intersect(
166 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $undoRev->getSlots() )
167 );
168 if ( !$rolesToMerge ) {
169 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
170 }
171
172 // Any roles with the same content in $oldRev and $curRev were already reverted
173 // and so can be inherited.
174 $rolesToMerge = array_intersect(
175 $rolesToMerge, $oldRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
176 );
177 if ( !$rolesToMerge ) {
178 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
179 }
180
181 // Any roles with the same content in $undoRev and $curRev weren't
182 // changed since and so can be reverted to $oldRev.
183 $diffRoles = array_intersect(
184 $rolesToMerge, $undoRev->getSlots()->getRolesWithDifferentContent( $curRev->getSlots() )
185 );
186 foreach ( array_diff( $rolesToMerge, $diffRoles ) as $role ) {
187 if ( $oldRev->hasSlot( $role ) ) {
188 $newRev->inheritSlot( $oldRev->getSlot( $role, RevisionRecord::RAW ) );
189 } else {
190 $newRev->removeSlot( $role );
191 }
192 }
193 $rolesToMerge = $diffRoles;
194
195 // Any slot additions or removals not handled by the above checks can't be undone.
196 // There will be only one of the three revisions missing the slot:
197 // - !old means it was added in the undone revisions and modified after.
198 // Should it be removed entirely for the undo, or should the modified version be kept?
199 // - !undo means it was removed in the undone revisions and then readded with different content.
200 // Which content is should be kept, the old or the new?
201 // - !cur means it was changed in the undone revisions and then deleted after.
202 // Did someone delete vandalized content instead of undoing (meaning we should ideally restore
203 // it), or should it stay gone?
204 foreach ( $rolesToMerge as $role ) {
205 if ( !$oldRev->hasSlot( $role ) || !$undoRev->hasSlot( $role ) || !$curRev->hasSlot( $role ) ) {
206 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
207 }
208 }
209
210 // Try to merge anything that's left.
211 foreach ( $rolesToMerge as $role ) {
212 $oldContent = $oldRev->getSlot( $role, RevisionRecord::RAW )->getContent();
213 $undoContent = $undoRev->getSlot( $role, RevisionRecord::RAW )->getContent();
214 $curContent = $curRev->getSlot( $role, RevisionRecord::RAW )->getContent();
215 $newContent = $undoContent->getContentHandler()
216 ->getUndoContent( $curContent, $undoContent, $oldContent, $isLatest );
217 if ( !$newContent ) {
218 throw new ErrorPageError( 'mcrundofailed', 'undo-failure' );
219 }
220 $newRev->setSlot( SlotRecord::newUnsaved( $role, $newContent ) );
221 }
222
223 return $newRev;
224 }
225
226 private function generateDiffOrPreview() {
227 $newRev = $this->getNewRevision();
228 if ( $newRev->hasSameContent( $this->curRev ) ) {
229 throw new ErrorPageError( 'mcrundofailed', 'undo-nochange' );
230 }
231
232 $diffEngine = new DifferenceEngine( $this->context );
233 $diffEngine->setRevisions( $this->curRev, $newRev );
234
235 $oldtitle = $this->context->msg( 'currentrev' )->parse();
236 $newtitle = $this->context->msg( 'yourtext' )->parse();
237
238 if ( $this->getRequest()->getCheck( 'wpPreview' ) ) {
239 $this->showPreview( $newRev );
240 return '';
241 } else {
242 $diffText = $diffEngine->getDiff( $oldtitle, $newtitle );
243 $diffEngine->showDiffStyle();
244 return '<div id="wikiDiff">' . $diffText . '</div>';
245 }
246 }
247
248 private function showPreview( RevisionRecord $rev ) {
249 // Mostly copied from EditPage::getPreviewText()
250 $out = $this->getOutput();
251
252 try {
253 $previewHTML = '';
254
255 # provide a anchor link to the form
256 $continueEditing = '<span class="mw-continue-editing">' .
257 '[[#mw-mcrundo-form|' .
258 $this->context->getLanguage()->getArrow() . ' ' .
259 $this->context->msg( 'continue-editing' )->text() . ']]</span>';
260
261 $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
262
263 $parserOptions = $this->page->makeParserOptions( $this->context );
264 $parserOptions->setIsPreview( true );
265 $parserOptions->setIsSectionPreview( false );
266 $parserOptions->enableLimitReport();
267
268 $parserOutput = MediaWikiServices::getInstance()->getRevisionRenderer()
269 ->getRenderedRevision( $rev, $parserOptions, $this->context->getUser() )
270 ->getRevisionParserOutput();
271 $previewHTML = $parserOutput->getText( [ 'enableSectionEditLinks' => false ] );
272
273 $out->addParserOutputMetadata( $parserOutput );
274 if ( count( $parserOutput->getWarnings() ) ) {
275 $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
276 }
277 } catch ( MWContentSerializationException $ex ) {
278 $m = $this->context->msg(
279 'content-failed-to-parse',
280 $ex->getMessage()
281 );
282 $note .= "\n\n" . $m->parse();
283 $previewHTML = '';
284 }
285
286 $previewhead = Html::rawElement(
287 'div', [ 'class' => 'previewnote' ],
288 Html::element(
289 'h2', [ 'id' => 'mw-previewheader' ],
290 $this->context->msg( 'preview' )->text()
291 ) .
292 $out->parseAsInterface( $note ) .
293 "<hr />"
294 );
295
296 $pageViewLang = $this->getTitle()->getPageViewLanguage();
297 $attribs = [ 'lang' => $pageViewLang->getHtmlCode(), 'dir' => $pageViewLang->getDir(),
298 'class' => 'mw-content-' . $pageViewLang->getDir() ];
299 $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML );
300
301 $out->addHTML( $previewhead . $previewHTML );
302 }
303
304 public function onSubmit( $data ) {
305 global $wgUseRCPatrol;
306
307 if ( !$this->getRequest()->getCheck( 'wpSave' ) ) {
308 // Diff or preview
309 return false;
310 }
311
312 $updater = $this->page->getPage()->newPageUpdater( $this->context->getUser() );
313 $curRev = $updater->grabParentRevision();
314 if ( !$curRev ) {
315 throw new ErrorPageError( 'mcrundofailed', 'nopagetext' );
316 }
317
318 if ( $this->cur !== $curRev->getId() ) {
319 return Status::newFatal( 'mcrundo-changed' );
320 }
321
322 $newRev = $this->getNewRevision();
323 if ( !$newRev->hasSameContent( $curRev ) ) {
324 // Copy new slots into the PageUpdater, and remove any removed slots.
325 // TODO: This interface is awful, there should be a way to just pass $newRev.
326 // TODO: MCR: test this once we can store multiple slots
327 foreach ( $newRev->getSlots()->getSlots() as $slot ) {
328 $updater->setSlot( $slot );
329 }
330 foreach ( $curRev->getSlotRoles() as $role ) {
331 if ( !$newRev->hasSlot( $role ) ) {
332 $updater->removeSlot( $role );
333 }
334 }
335
336 $updater->setOriginalRevisionId( false );
337 $updater->setUndidRevisionId( $this->undo );
338
339 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
340
341 // TODO: Ugh.
342 if ( $wgUseRCPatrol && $permissionManager->userCan(
343 'autopatrol',
344 $this->getUser(),
345 $this->getTitle() )
346 ) {
347 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
348 }
349
350 $updater->saveRevision(
351 CommentStoreComment::newUnsavedComment( trim( $this->getRequest()->getVal( 'wpSummary' ) ) ),
352 EDIT_AUTOSUMMARY | EDIT_UPDATE
353 );
354
355 return $updater->getStatus();
356 }
357
358 return Status::newGood();
359 }
360
361 protected function usesOOUI() {
362 return true;
363 }
364
365 protected function getFormFields() {
366 $request = $this->getRequest();
367 $ret = [
368 'diff' => [
369 'type' => 'info',
370 'vertical-label' => true,
371 'raw' => true,
372 'default' => function () {
373 return $this->generateDiffOrPreview();
374 }
375 ],
376 'summary' => [
377 'type' => 'text',
378 'id' => 'wpSummary',
379 'name' => 'wpSummary',
380 'cssclass' => 'mw-summary',
381 'label-message' => 'summary',
382 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
383 'value' => $request->getVal( 'wpSummary', '' ),
384 'size' => 60,
385 'spellcheck' => 'true',
386 ],
387 'summarypreview' => [
388 'type' => 'info',
389 'label-message' => 'summary-preview',
390 'raw' => true,
391 ],
392 ];
393
394 if ( $request->getCheck( 'wpSummary' ) ) {
395 $ret['summarypreview']['default'] = Xml::tags( 'div', [ 'class' => 'mw-summary-preview' ],
396 Linker::commentBlock( trim( $request->getVal( 'wpSummary' ) ), $this->getTitle(), false )
397 );
398 } else {
399 unset( $ret['summarypreview'] );
400 }
401
402 return $ret;
403 }
404
405 protected function alterForm( HTMLForm $form ) {
406 $form->setWrapperLegendMsg( 'confirm-mcrundo-title' );
407
408 $labelAsPublish = $this->context->getConfig()->get( 'EditSubmitButtonLabelPublish' );
409
410 $form->setId( 'mw-mcrundo-form' );
411 $form->setSubmitName( 'wpSave' );
412 $form->setSubmitTooltip( $labelAsPublish ? 'publish' : 'save' );
413 $form->setSubmitTextMsg( $labelAsPublish ? 'publishchanges' : 'savechanges' );
414 $form->showCancel( true );
415 $form->setCancelTarget( $this->getTitle() );
416 $form->addButton( [
417 'name' => 'wpPreview',
418 'value' => '1',
419 'label-message' => 'showpreview',
420 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'preview' ),
421 ] );
422 $form->addButton( [
423 'name' => 'wpDiff',
424 'value' => '1',
425 'label-message' => 'showdiff',
426 'attribs' => Linker::tooltipAndAccesskeyAttribs( 'diff' ),
427 ] );
428
429 $this->addStatePropagationFields( $form );
430 }
431
432 protected function addStatePropagationFields( HTMLForm $form ) {
433 $form->addHiddenField( 'undo', $this->undo );
434 $form->addHiddenField( 'undoafter', $this->undoafter );
435 $form->addHiddenField( 'cur', $this->curRev->getId() );
436 }
437
438 public function onSuccess() {
439 $this->getOutput()->redirect( $this->getTitle()->getFullURL() );
440 }
441
442 protected function preText() {
443 return '<div style="clear:both"></div>';
444 }
445 }