includes/specials/SpecialMergeHistory.php
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
namespace MediaWiki\Specials;
use LogEventsList;
use LogPage;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Page\MergeHistoryFactory;
use MediaWiki\Pager\MergeHistoryPager;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IConnectionProvider;
/**
* Combine the revision history of two articles into one.
*
* Limited to users with the appropriate permissions,
* and with some restrictions on whether a page's history can be
* merged.
*
* @ingroup SpecialPage
*/
class SpecialMergeHistory extends SpecialPage {
/** @var string */
protected $mAction;
/** @var string */
protected $mTarget;
/** @var string */
protected $mDest;
/** @var string */
protected $mTimestamp;
/** @var int */
protected $mTargetID;
/** @var int */
protected $mDestID;
/** @var string */
protected $mComment;
/** @var bool Was posted? */
protected $mMerge;
/** @var bool Was submitted? */
protected $mSubmitted;
/** @var Title|null */
protected $mTargetObj;
/** @var Title|null */
protected $mDestObj;
private MergeHistoryFactory $mergeHistoryFactory;
private LinkBatchFactory $linkBatchFactory;
private IConnectionProvider $dbProvider;
private RevisionStore $revisionStore;
private CommentFormatter $commentFormatter;
/** @var Status */
private $mStatus;
/**
* @param MergeHistoryFactory $mergeHistoryFactory
* @param LinkBatchFactory $linkBatchFactory
* @param IConnectionProvider $dbProvider
* @param RevisionStore $revisionStore
* @param CommentFormatter $commentFormatter
*/
public function __construct(
MergeHistoryFactory $mergeHistoryFactory,
LinkBatchFactory $linkBatchFactory,
IConnectionProvider $dbProvider,
RevisionStore $revisionStore,
CommentFormatter $commentFormatter
) {
parent::__construct( 'MergeHistory', 'mergehistory' );
$this->mergeHistoryFactory = $mergeHistoryFactory;
$this->linkBatchFactory = $linkBatchFactory;
$this->dbProvider = $dbProvider;
$this->revisionStore = $revisionStore;
$this->commentFormatter = $commentFormatter;
}
public function doesWrites() {
return true;
}
/**
* @return void
*/
private function loadRequestParams() {
$request = $this->getRequest();
$this->mAction = $request->getRawVal( 'action' );
$this->mTarget = $request->getVal( 'target', '' );
$this->mDest = $request->getVal( 'dest', '' );
$this->mSubmitted = $request->getBool( 'submitted' );
$this->mTargetID = intval( $request->getVal( 'targetID' ) );
$this->mDestID = intval( $request->getVal( 'destID' ) );
$this->mTimestamp = $request->getVal( 'mergepoint' );
if ( $this->mTimestamp === null || !preg_match( '/[0-9]{14}(\|[0-9]+)?/', $this->mTimestamp ) ) {
$this->mTimestamp = '';
}
$this->mComment = $request->getText( 'wpComment' );
$this->mMerge = $request->wasPosted()
&& $this->getContext()->getCsrfTokenSet()->matchToken( $request->getVal( 'wpEditToken' ) );
// target page
if ( $this->mSubmitted ) {
$this->mTargetObj = Title::newFromText( $this->mTarget );
$this->mDestObj = Title::newFromText( $this->mDest );
} else {
$this->mTargetObj = null;
$this->mDestObj = null;
}
}
public function execute( $par ) {
$this->useTransactionalTimeLimit();
$this->checkPermissions();
$this->checkReadOnly();
$this->loadRequestParams();
$this->setHeaders();
$this->outputHeader();
$status = Status::newGood();
if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) {
$this->merge();
return;
}
if ( !$this->mSubmitted ) {
$this->showMergeForm();
return;
}
if ( !$this->mTargetObj instanceof Title ) {
$status->merge( Status::newFatal( 'mergehistory-invalid-source' ) );
} elseif ( !$this->mTargetObj->exists() ) {
$status->merge( Status::newFatal(
'mergehistory-no-source',
wfEscapeWikiText( $this->mTargetObj->getPrefixedText() )
) );
}
if ( !$this->mDestObj instanceof Title ) {
$status->merge( Status::newFatal( 'mergehistory-invalid-destination' ) );
} elseif ( !$this->mDestObj->exists() ) {
$status->merge( Status::newFatal(
'mergehistory-no-destination',
wfEscapeWikiText( $this->mDestObj->getPrefixedText() )
) );
}
if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) {
$status->merge( Status::newFatal( 'mergehistory-same-destination' ) );
}
$this->mStatus = $status;
$this->showMergeForm();
if ( $this->mStatus->isGood() ) {
$this->showHistory();
}
}
private function showMergeForm() {
$out = $this->getOutput();
$out->addWikiMsg( 'mergehistory-header' );
$fields = [
'submitted' => [
'type' => 'hidden',
'default' => '1',
'name' => 'submitted'
],
'title' => [
'type' => 'hidden',
'default' => $this->getPageTitle()->getPrefixedDBkey(),
'name' => 'title'
],
'mergepoint' => [
'type' => 'hidden',
'default' => $this->mTimestamp,
'name' => 'mergepoint'
],
'target' => [
'type' => 'title',
'label-message' => 'mergehistory-from',
'default' => $this->mTarget,
'id' => 'target',
'name' => 'target'
],
'dest' => [
'type' => 'title',
'label-message' => 'mergehistory-into',
'default' => $this->mDest,
'id' => 'dest',
'name' => 'dest'
]
];
$form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
$form->setWrapperLegendMsg( 'mergehistory-box' )
->setSubmitTextMsg( 'mergehistory-go' )
->setMethod( 'get' )
->prepareForm()
->displayForm( $this->mStatus );
$this->addHelpLink( 'Help:Merge history' );
}
private function showHistory() {
# List all stored revisions
$revisions = new MergeHistoryPager(
$this->getContext(),
$this->getLinkRenderer(),
$this->linkBatchFactory,
$this->dbProvider,
$this->revisionStore,
$this->commentFormatter,
[],
$this->mTargetObj,
$this->mDestObj,
$this->mTimestamp
);
$haveRevisions = $revisions->getNumRows() > 0;
$out = $this->getOutput();
$out->addModuleStyles( [
'mediawiki.interface.helpers.styles',
'mediawiki.special'
] );
$titleObj = $this->getPageTitle();
$action = $titleObj->getLocalURL( [ 'action' => 'submit' ] );
# Start the form here
$fields = [
'targetID' => [
'type' => 'hidden',
'name' => 'targetID',
'default' => $this->mTargetObj->getArticleID()
],
'destID' => [
'type' => 'hidden',
'name' => 'destID',
'default' => $this->mDestObj->getArticleID()
],
'target' => [
'type' => 'hidden',
'name' => 'target',
'default' => $this->mTarget
],
'dest' => [
'type' => 'hidden',
'name' => 'dest',
'default' => $this->mDest
],
];
if ( $haveRevisions ) {
$fields += [
'explanation' => [
'type' => 'info',
'default' => $this->msg( 'mergehistory-merge', $this->mTargetObj->getPrefixedText(),
$this->mDestObj->getPrefixedText() )->parse(),
'raw' => true,
'cssclass' => 'mw-mergehistory-explanation',
'section' => 'mergehistory-submit'
],
'reason' => [
'type' => 'text',
'name' => 'wpComment',
'label-message' => 'mergehistory-reason',
'size' => 50,
'default' => $this->mComment,
'section' => 'mergehistory-submit'
],
'submit' => [
'type' => 'submit',
'default' => $this->msg( 'mergehistory-submit' ),
'section' => 'mergehistory-submit',
'id' => 'mw-merge-submit',
'name' => 'merge'
]
];
}
$form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
$form->addHiddenField( 'wpEditToken', $form->getCsrfTokenSet()->getToken() )
->setId( 'merge' )
->setAction( $action )
->suppressDefaultSubmit();
if ( $haveRevisions ) {
$form->setFooterHtml(
'<h2 id="mw-mergehistory">' . $this->msg( 'mergehistory-list' )->escaped() . '</h2>' .
$revisions->getNavigationBar() .
$revisions->getBody() .
$revisions->getNavigationBar()
);
} else {
$form->setFooterHtml( $this->msg( 'mergehistory-empty' ) );
}
$form->prepareForm()->displayForm( false );
# Show relevant lines from the merge log:
$mergeLogPage = new LogPage( 'merge' );
$out->addHTML( '<h2>' . $mergeLogPage->getName()->escaped() . "</h2>\n" );
LogEventsList::showLogExtract( $out, 'merge', $this->mTargetObj );
return true;
}
/**
* Actually attempt the history move
*
* @todo if all versions of page A are moved to B and then a user
* tries to do a reverse-merge via the "unmerge" log link, then page
* A will still be a redirect (as it was after the original merge),
* though it will have the old revisions back from before (as expected).
* The user may have to "undo" the redirect manually to finish the "unmerge".
* Maybe this should delete redirects at the target page of merges?
*
* @return bool Success
*/
private function merge() {
# Get the titles directly from the IDs, in case the target page params
# were spoofed. The queries are done based on the IDs, so it's best to
# keep it consistent...
$targetTitle = Title::newFromID( $this->mTargetID );
$destTitle = Title::newFromID( $this->mDestID );
if ( $targetTitle === null || $destTitle === null ) {
return false; // validate these
}
if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
return false;
}
// MergeHistory object
$mh = $this->mergeHistoryFactory->newMergeHistory( $targetTitle, $destTitle, $this->mTimestamp );
// Merge!
$mergeStatus = $mh->merge( $this->getAuthority(), $this->mComment );
if ( !$mergeStatus->isOK() ) {
// Failed merge
$this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
return false;
}
$linkRenderer = $this->getLinkRenderer();
$targetLink = $linkRenderer->makeLink(
$targetTitle,
null,
[],
[ 'redirect' => 'no' ]
);
// In some cases the target page will be deleted
$append = ( $mergeStatus->getValue() === 'source-deleted' )
? $this->msg( 'mergehistory-source-deleted', $targetTitle->getPrefixedText() ) : '';
$this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
->rawParams( $targetLink )
->params( $destTitle->getPrefixedText(), $append )
->numParams( $mh->getMergedRevisionCount() )
);
return true;
}
protected function getGroupName() {
return 'pagetools';
}
}
/**
* Retain the old class name for backwards compatibility.
* @deprecated since 1.41
*/
class_alias( SpecialMergeHistory::class, 'SpecialMergeHistory' );