1<?php 2/** 3 * Implements Special:MergeHistory 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup SpecialPage 22 */ 23 24use MediaWiki\MediaWikiServices; 25use MediaWiki\Revision\RevisionRecord; 26 27/** 28 * Special page allowing users with the appropriate permissions to 29 * merge article histories, with some restrictions 30 * 31 * @ingroup SpecialPage 32 */ 33class SpecialMergeHistory extends SpecialPage { 34 /** @var string */ 35 protected $mAction; 36 37 /** @var string */ 38 protected $mTarget; 39 40 /** @var string */ 41 protected $mDest; 42 43 /** @var string */ 44 protected $mTimestamp; 45 46 /** @var int */ 47 protected $mTargetID; 48 49 /** @var int */ 50 protected $mDestID; 51 52 /** @var string */ 53 protected $mComment; 54 55 /** @var bool Was posted? */ 56 protected $mMerge; 57 58 /** @var bool Was submitted? */ 59 protected $mSubmitted; 60 61 /** @var Title */ 62 protected $mTargetObj; 63 64 /** @var Title */ 65 protected $mDestObj; 66 67 /** @var int[] */ 68 public $prevId; 69 70 public function __construct() { 71 parent::__construct( 'MergeHistory', 'mergehistory' ); 72 } 73 74 public function doesWrites() { 75 return true; 76 } 77 78 /** 79 * @return void 80 */ 81 private function loadRequestParams() { 82 $request = $this->getRequest(); 83 $this->mAction = $request->getVal( 'action' ); 84 $this->mTarget = $request->getVal( 'target' ); 85 $this->mDest = $request->getVal( 'dest' ); 86 $this->mSubmitted = $request->getBool( 'submitted' ); 87 88 $this->mTargetID = intval( $request->getVal( 'targetID' ) ); 89 $this->mDestID = intval( $request->getVal( 'destID' ) ); 90 $this->mTimestamp = $request->getVal( 'mergepoint' ); 91 if ( !preg_match( '/[0-9]{14}/', $this->mTimestamp ) ) { 92 $this->mTimestamp = ''; 93 } 94 $this->mComment = $request->getText( 'wpComment' ); 95 96 $this->mMerge = $request->wasPosted() 97 && $this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) ); 98 99 // target page 100 if ( $this->mSubmitted ) { 101 $this->mTargetObj = Title::newFromText( $this->mTarget ); 102 $this->mDestObj = Title::newFromText( $this->mDest ); 103 } else { 104 $this->mTargetObj = null; 105 $this->mDestObj = null; 106 } 107 } 108 109 public function execute( $par ) { 110 $this->useTransactionalTimeLimit(); 111 112 $this->checkPermissions(); 113 $this->checkReadOnly(); 114 115 $this->loadRequestParams(); 116 117 $this->setHeaders(); 118 $this->outputHeader(); 119 120 if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) { 121 $this->merge(); 122 123 return; 124 } 125 126 if ( !$this->mSubmitted ) { 127 $this->showMergeForm(); 128 129 return; 130 } 131 132 $errors = []; 133 if ( !$this->mTargetObj instanceof Title ) { 134 $errors[] = $this->msg( 'mergehistory-invalid-source' )->parseAsBlock(); 135 } elseif ( !$this->mTargetObj->exists() ) { 136 $errors[] = $this->msg( 'mergehistory-no-source', 137 wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) 138 )->parseAsBlock(); 139 } 140 141 if ( !$this->mDestObj instanceof Title ) { 142 $errors[] = $this->msg( 'mergehistory-invalid-destination' )->parseAsBlock(); 143 } elseif ( !$this->mDestObj->exists() ) { 144 $errors[] = $this->msg( 'mergehistory-no-destination', 145 wfEscapeWikiText( $this->mDestObj->getPrefixedText() ) 146 )->parseAsBlock(); 147 } 148 149 if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) { 150 $errors[] = $this->msg( 'mergehistory-same-destination' )->parseAsBlock(); 151 } 152 153 if ( count( $errors ) ) { 154 $this->showMergeForm(); 155 $this->getOutput()->addHTML( implode( "\n", $errors ) ); 156 } else { 157 $this->showHistory(); 158 } 159 } 160 161 private function showMergeForm() { 162 $out = $this->getOutput(); 163 $out->addWikiMsg( 'mergehistory-header' ); 164 165 $out->addHTML( 166 Xml::openElement( 'form', [ 167 'method' => 'get', 168 'action' => wfScript() ] ) . 169 '<fieldset>' . 170 Xml::element( 'legend', [], 171 $this->msg( 'mergehistory-box' )->text() ) . 172 Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . 173 Html::hidden( 'submitted', '1' ) . 174 Html::hidden( 'mergepoint', $this->mTimestamp ) . 175 Xml::openElement( 'table' ) . 176 '<tr> 177 <td>' . Xml::label( $this->msg( 'mergehistory-from' )->text(), 'target' ) . '</td> 178 <td>' . Xml::input( 'target', 30, $this->mTarget, [ 'id' => 'target' ] ) . '</td> 179 </tr><tr> 180 <td>' . Xml::label( $this->msg( 'mergehistory-into' )->text(), 'dest' ) . '</td> 181 <td>' . Xml::input( 'dest', 30, $this->mDest, [ 'id' => 'dest' ] ) . '</td> 182 </tr><tr><td>' . 183 Xml::submitButton( $this->msg( 'mergehistory-go' )->text() ) . 184 '</td></tr>' . 185 Xml::closeElement( 'table' ) . 186 '</fieldset>' . 187 '</form>' 188 ); 189 190 $this->addHelpLink( 'Help:Merge history' ); 191 } 192 193 private function showHistory() { 194 $this->showMergeForm(); 195 196 # List all stored revisions 197 $revisions = new MergeHistoryPager( 198 $this, [], $this->mTargetObj, $this->mDestObj 199 ); 200 $haveRevisions = $revisions->getNumRows() > 0; 201 202 $out = $this->getOutput(); 203 $titleObj = $this->getPageTitle(); 204 $action = $titleObj->getLocalURL( [ 'action' => 'submit' ] ); 205 # Start the form here 206 $top = Xml::openElement( 207 'form', 208 [ 209 'method' => 'post', 210 'action' => $action, 211 'id' => 'merge' 212 ] 213 ); 214 $out->addHTML( $top ); 215 216 if ( $haveRevisions ) { 217 # Format the user-visible controls (comment field, submission button) 218 # in a nice little table 219 $table = 220 Xml::openElement( 'fieldset' ) . 221 $this->msg( 'mergehistory-merge', $this->mTargetObj->getPrefixedText(), 222 $this->mDestObj->getPrefixedText() )->parse() . 223 Xml::openElement( 'table', [ 'id' => 'mw-mergehistory-table' ] ) . 224 '<tr> 225 <td class="mw-label">' . 226 Xml::label( $this->msg( 'mergehistory-reason' )->text(), 'wpComment' ) . 227 '</td> 228 <td class="mw-input">' . 229 Xml::input( 'wpComment', 50, $this->mComment, [ 'id' => 'wpComment' ] ) . 230 "</td> 231 </tr> 232 <tr> 233 <td>\u{00A0}</td> 234 <td class=\"mw-submit\">" . 235 Xml::submitButton( 236 $this->msg( 'mergehistory-submit' )->text(), 237 [ 'name' => 'merge', 'id' => 'mw-merge-submit' ] 238 ) . 239 '</td> 240 </tr>' . 241 Xml::closeElement( 'table' ) . 242 Xml::closeElement( 'fieldset' ); 243 244 $out->addHTML( $table ); 245 } 246 247 $out->addHTML( 248 '<h2 id="mw-mergehistory">' . 249 $this->msg( 'mergehistory-list' )->escaped() . "</h2>\n" 250 ); 251 252 if ( $haveRevisions ) { 253 $out->addHTML( $revisions->getNavigationBar() ); 254 $out->addHTML( '<ul>' ); 255 $out->addHTML( $revisions->getBody() ); 256 $out->addHTML( '</ul>' ); 257 $out->addHTML( $revisions->getNavigationBar() ); 258 } else { 259 $out->addWikiMsg( 'mergehistory-empty' ); 260 } 261 262 # Show relevant lines from the merge log: 263 $mergeLogPage = new LogPage( 'merge' ); 264 $out->addHTML( '<h2>' . $mergeLogPage->getName()->escaped() . "</h2>\n" ); 265 LogEventsList::showLogExtract( $out, 'merge', $this->mTargetObj ); 266 267 # When we submit, go by page ID to avoid some nasty but unlikely collisions. 268 # Such would happen if a page was renamed after the form loaded, but before submit 269 $misc = Html::hidden( 'targetID', $this->mTargetObj->getArticleID() ); 270 $misc .= Html::hidden( 'destID', $this->mDestObj->getArticleID() ); 271 $misc .= Html::hidden( 'target', $this->mTarget ); 272 $misc .= Html::hidden( 'dest', $this->mDest ); 273 $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ); 274 $misc .= Xml::closeElement( 'form' ); 275 $out->addHTML( $misc ); 276 277 return true; 278 } 279 280 public function formatRevisionRow( $row ) { 281 $revRecord = MediaWikiServices::getInstance() 282 ->getRevisionFactory() 283 ->newRevisionFromRow( $row ); 284 285 $linkRenderer = $this->getLinkRenderer(); 286 287 $stxt = ''; 288 $last = $this->msg( 'last' )->escaped(); 289 290 $ts = wfTimestamp( TS_MW, $row->rev_timestamp ); 291 $checkBox = Xml::radio( 'mergepoint', $ts, ( $this->mTimestamp === $ts ) ); 292 293 $user = $this->getUser(); 294 295 $pageLink = $linkRenderer->makeKnownLink( 296 $revRecord->getPageAsLinkTarget(), 297 $this->getLanguage()->userTimeAndDate( $ts, $user ), 298 [], 299 [ 'oldid' => $revRecord->getId() ] 300 ); 301 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) { 302 $pageLink = '<span class="history-deleted">' . $pageLink . '</span>'; 303 } 304 305 # Last link 306 if ( !RevisionRecord::userCanBitfield( 307 $revRecord->getVisibility(), 308 RevisionRecord::DELETED_TEXT, 309 $user 310 ) ) { 311 $last = $this->msg( 'last' )->escaped(); 312 } elseif ( isset( $this->prevId[$row->rev_id] ) ) { 313 $last = $linkRenderer->makeKnownLink( 314 $revRecord->getPageAsLinkTarget(), 315 $this->msg( 'last' )->text(), 316 [], 317 [ 318 'diff' => $row->rev_id, 319 'oldid' => $this->prevId[$row->rev_id] 320 ] 321 ); 322 } 323 324 $userLink = Linker::revUserTools( $revRecord ); 325 326 $size = $row->rev_len; 327 if ( $size !== null ) { 328 $stxt = Linker::formatRevisionSize( $size ); 329 } 330 $comment = Linker::revComment( $revRecord ); 331 332 return Html::rawElement( 'li', [], 333 $this->msg( 'mergehistory-revisionrow' ) 334 ->rawParams( $checkBox, $last, $pageLink, $userLink, $stxt, $comment )->escaped() ); 335 } 336 337 /** 338 * Actually attempt the history move 339 * 340 * @todo if all versions of page A are moved to B and then a user 341 * tries to do a reverse-merge via the "unmerge" log link, then page 342 * A will still be a redirect (as it was after the original merge), 343 * though it will have the old revisions back from before (as expected). 344 * The user may have to "undo" the redirect manually to finish the "unmerge". 345 * Maybe this should delete redirects at the target page of merges? 346 * 347 * @return bool Success 348 */ 349 private function merge() { 350 # Get the titles directly from the IDs, in case the target page params 351 # were spoofed. The queries are done based on the IDs, so it's best to 352 # keep it consistent... 353 $targetTitle = Title::newFromID( $this->mTargetID ); 354 $destTitle = Title::newFromID( $this->mDestID ); 355 if ( $targetTitle === null || $destTitle === null ) { 356 return false; // validate these 357 } 358 if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) { 359 return false; 360 } 361 362 // MergeHistory object 363 $factory = MediaWikiServices::getInstance()->getMergeHistoryFactory(); 364 $mh = $factory->newMergeHistory( $targetTitle, $destTitle, $this->mTimestamp ); 365 366 // Merge! 367 $mergeStatus = $mh->merge( $this->getUser(), $this->mComment ); 368 if ( !$mergeStatus->isOK() ) { 369 // Failed merge 370 $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() ); 371 return false; 372 } 373 374 $linkRenderer = $this->getLinkRenderer(); 375 376 $targetLink = $linkRenderer->makeLink( 377 $targetTitle, 378 null, 379 [], 380 [ 'redirect' => 'no' ] 381 ); 382 383 $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' ) 384 ->rawParams( $targetLink ) 385 ->params( $destTitle->getPrefixedText() ) 386 ->numParams( $mh->getMergedRevisionCount() ) 387 ); 388 389 return true; 390 } 391 392 protected function getGroupName() { 393 return 'pagetools'; 394 } 395} 396