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