McrUndoAction.php 14 KB

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