3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 namespace MediaWiki\Specials
;
25 use MediaWiki\Cache\LinkBatchFactory
;
26 use MediaWiki\CommentFormatter\CommentFormatter
;
27 use MediaWiki\HTMLForm\HTMLForm
;
28 use MediaWiki\Page\MergeHistoryFactory
;
29 use MediaWiki\Pager\MergeHistoryPager
;
30 use MediaWiki\Revision\RevisionStore
;
31 use MediaWiki\SpecialPage\SpecialPage
;
32 use MediaWiki\Status\Status
;
33 use MediaWiki\Title\Title
;
34 use Wikimedia\Rdbms\IConnectionProvider
;
37 * Combine the revision history of two articles into one.
39 * Limited to users with the appropriate permissions,
40 * and with some restrictions on whether a page's history can be
43 * @ingroup SpecialPage
45 class SpecialMergeHistory
extends SpecialPage
{
46 /** @var string|null */
56 protected $mTimestamp;
67 /** @var bool Was posted? */
70 /** @var bool Was submitted? */
71 protected $mSubmitted;
73 /** @var Title|null */
74 protected $mTargetObj;
76 /** @var Title|null */
79 private MergeHistoryFactory
$mergeHistoryFactory;
80 private LinkBatchFactory
$linkBatchFactory;
81 private IConnectionProvider
$dbProvider;
82 private RevisionStore
$revisionStore;
83 private CommentFormatter
$commentFormatter;
89 * @param MergeHistoryFactory $mergeHistoryFactory
90 * @param LinkBatchFactory $linkBatchFactory
91 * @param IConnectionProvider $dbProvider
92 * @param RevisionStore $revisionStore
93 * @param CommentFormatter $commentFormatter
95 public function __construct(
96 MergeHistoryFactory
$mergeHistoryFactory,
97 LinkBatchFactory
$linkBatchFactory,
98 IConnectionProvider
$dbProvider,
99 RevisionStore
$revisionStore,
100 CommentFormatter
$commentFormatter
102 parent
::__construct( 'MergeHistory', 'mergehistory' );
103 $this->mergeHistoryFactory
= $mergeHistoryFactory;
104 $this->linkBatchFactory
= $linkBatchFactory;
105 $this->dbProvider
= $dbProvider;
106 $this->revisionStore
= $revisionStore;
107 $this->commentFormatter
= $commentFormatter;
110 public function doesWrites() {
117 private function loadRequestParams() {
118 $request = $this->getRequest();
119 $this->mAction
= $request->getRawVal( 'action' );
120 $this->mTarget
= $request->getVal( 'target', '' );
121 $this->mDest
= $request->getVal( 'dest', '' );
122 $this->mSubmitted
= $request->getBool( 'submitted' );
124 $this->mTargetID
= intval( $request->getVal( 'targetID' ) );
125 $this->mDestID
= intval( $request->getVal( 'destID' ) );
126 $this->mTimestamp
= $request->getVal( 'mergepoint' );
127 if ( $this->mTimestamp
=== null ||
!preg_match( '/[0-9]{14}(\|[0-9]+)?/', $this->mTimestamp
) ) {
128 $this->mTimestamp
= '';
130 $this->mComment
= $request->getText( 'wpComment' );
132 $this->mMerge
= $request->wasPosted()
133 && $this->getContext()->getCsrfTokenSet()->matchToken( $request->getVal( 'wpEditToken' ) );
136 if ( $this->mSubmitted
) {
137 $this->mTargetObj
= Title
::newFromText( $this->mTarget
);
138 $this->mDestObj
= Title
::newFromText( $this->mDest
);
140 $this->mTargetObj
= null;
141 $this->mDestObj
= null;
145 public function execute( $par ) {
146 $this->useTransactionalTimeLimit();
148 $this->checkPermissions();
149 $this->checkReadOnly();
151 $this->loadRequestParams();
154 $this->outputHeader();
155 $status = Status
::newGood();
157 if ( $this->mTargetID
&& $this->mDestID
&& $this->mAction
== 'submit' && $this->mMerge
) {
163 if ( !$this->mSubmitted
) {
164 $this->showMergeForm();
169 if ( !$this->mTargetObj
instanceof Title
) {
170 $status->merge( Status
::newFatal( 'mergehistory-invalid-source' ) );
171 } elseif ( !$this->mTargetObj
->exists() ) {
172 $status->merge( Status
::newFatal(
173 'mergehistory-no-source',
174 wfEscapeWikiText( $this->mTargetObj
->getPrefixedText() )
178 if ( !$this->mDestObj
instanceof Title
) {
179 $status->merge( Status
::newFatal( 'mergehistory-invalid-destination' ) );
180 } elseif ( !$this->mDestObj
->exists() ) {
181 $status->merge( Status
::newFatal(
182 'mergehistory-no-destination',
183 wfEscapeWikiText( $this->mDestObj
->getPrefixedText() )
187 if ( $this->mTargetObj
&& $this->mDestObj
&& $this->mTargetObj
->equals( $this->mDestObj
) ) {
188 $status->merge( Status
::newFatal( 'mergehistory-same-destination' ) );
191 $this->mStatus
= $status;
193 $this->showMergeForm();
195 if ( $this->mStatus
->isGood() ) {
196 $this->showHistory();
200 private function showMergeForm() {
201 $out = $this->getOutput();
202 $out->addWikiMsg( 'mergehistory-header' );
208 'name' => 'submitted'
212 'default' => $this->getPageTitle()->getPrefixedDBkey(),
217 'default' => $this->mTimestamp
,
218 'name' => 'mergepoint'
222 'label-message' => 'mergehistory-from',
223 'default' => $this->mTarget
,
229 'label-message' => 'mergehistory-into',
230 'default' => $this->mDest
,
236 $form = HTMLForm
::factory( 'ooui', $fields, $this->getContext() );
237 $form->setWrapperLegendMsg( 'mergehistory-box' )
238 ->setSubmitTextMsg( 'mergehistory-go' )
241 ->displayForm( $this->mStatus
);
243 $this->addHelpLink( 'Help:Merge history' );
246 private function showHistory() {
247 # List all stored revisions
248 $revisions = new MergeHistoryPager(
250 $this->getLinkRenderer(),
251 $this->linkBatchFactory
,
253 $this->revisionStore
,
254 $this->commentFormatter
,
260 $haveRevisions = $revisions->getNumRows() > 0;
262 $out = $this->getOutput();
263 $out->addModuleStyles( [
264 'mediawiki.interface.helpers.styles',
267 $titleObj = $this->getPageTitle();
268 $action = $titleObj->getLocalURL( [ 'action' => 'submit' ] );
269 # Start the form here
273 'name' => 'targetID',
274 'default' => $this->mTargetObj
->getArticleID()
279 'default' => $this->mDestObj
->getArticleID()
284 'default' => $this->mTarget
289 'default' => $this->mDest
292 if ( $haveRevisions ) {
296 'default' => $this->msg( 'mergehistory-merge', $this->mTargetObj
->getPrefixedText(),
297 $this->mDestObj
->getPrefixedText() )->parse(),
299 'cssclass' => 'mw-mergehistory-explanation',
300 'section' => 'mergehistory-submit'
304 'name' => 'wpComment',
305 'label-message' => 'mergehistory-reason',
307 'default' => $this->mComment
,
308 'section' => 'mergehistory-submit'
312 'default' => $this->msg( 'mergehistory-submit' ),
313 'section' => 'mergehistory-submit',
314 'id' => 'mw-merge-submit',
319 $form = HTMLForm
::factory( 'ooui', $fields, $this->getContext() );
320 $form->addHiddenField( 'wpEditToken', $form->getCsrfTokenSet()->getToken() )
322 ->setAction( $action )
323 ->suppressDefaultSubmit();
325 if ( $haveRevisions ) {
326 $form->setFooterHtml(
327 '<h2 id="mw-mergehistory">' . $this->msg( 'mergehistory-list' )->escaped() . '</h2>' .
328 $revisions->getNavigationBar() .
329 $revisions->getBody() .
330 $revisions->getNavigationBar()
333 $form->setFooterHtml( $this->msg( 'mergehistory-empty' ) );
336 $form->prepareForm()->displayForm( false );
338 # Show relevant lines from the merge log:
339 $mergeLogPage = new LogPage( 'merge' );
340 $out->addHTML( '<h2>' . $mergeLogPage->getName()->escaped() . "</h2>\n" );
341 LogEventsList
::showLogExtract( $out, 'merge', $this->mTargetObj
);
347 * Actually attempt the history move
349 * @todo if all versions of page A are moved to B and then a user
350 * tries to do a reverse-merge via the "unmerge" log link, then page
351 * A will still be a redirect (as it was after the original merge),
352 * though it will have the old revisions back from before (as expected).
353 * The user may have to "undo" the redirect manually to finish the "unmerge".
354 * Maybe this should delete redirects at the target page of merges?
356 * @return bool Success
358 private function merge() {
359 # Get the titles directly from the IDs, in case the target page params
360 # were spoofed. The queries are done based on the IDs, so it's best to
361 # keep it consistent...
362 $targetTitle = Title
::newFromID( $this->mTargetID
);
363 $destTitle = Title
::newFromID( $this->mDestID
);
364 if ( $targetTitle === null ||
$destTitle === null ) {
365 return false; // validate these
367 if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
371 // MergeHistory object
372 $mh = $this->mergeHistoryFactory
->newMergeHistory( $targetTitle, $destTitle, $this->mTimestamp
);
375 $mergeStatus = $mh->merge( $this->getAuthority(), $this->mComment
);
376 if ( !$mergeStatus->isOK() ) {
378 $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
382 $linkRenderer = $this->getLinkRenderer();
384 $targetLink = $linkRenderer->makeLink(
388 [ 'redirect' => 'no' ]
391 // In some cases the target page will be deleted
392 $append = ( $mergeStatus->getValue() === 'source-deleted' )
393 ?
$this->msg( 'mergehistory-source-deleted', $targetTitle->getPrefixedText() ) : '';
395 $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
396 ->rawParams( $targetLink )
397 ->params( $destTitle->getPrefixedText(), $append )
398 ->numParams( $mh->getMergedRevisionCount() )
404 protected function getGroupName() {
410 * Retain the old class name for backwards compatibility.
411 * @deprecated since 1.41
413 class_alias( SpecialMergeHistory
::class, 'SpecialMergeHistory' );