Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMergeHistory
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 8
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadRequestParams
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 execute
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
210
 showMergeForm
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
2
 showHistory
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
12
 merge
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\ChangeTags\ChangeTagsStore;
10use MediaWiki\CommentFormatter\CommentFormatter;
11use MediaWiki\HTMLForm\HTMLForm;
12use MediaWiki\Logging\LogEventsList;
13use MediaWiki\Logging\LogPage;
14use MediaWiki\Page\LinkBatchFactory;
15use MediaWiki\Page\MergeHistoryFactory;
16use MediaWiki\Pager\MergeHistoryPager;
17use MediaWiki\Revision\RevisionStore;
18use MediaWiki\SpecialPage\SpecialPage;
19use MediaWiki\Status\Status;
20use MediaWiki\Title\Title;
21use Wikimedia\Rdbms\IConnectionProvider;
22
23/**
24 * Combine the revision history of two articles into one.
25 *
26 * Limited to users with the appropriate permissions,
27 * and with some restrictions on whether a page's history can be
28 * merged.
29 *
30 * @ingroup SpecialPage
31 */
32class SpecialMergeHistory extends SpecialPage {
33    /** @var string|null */
34    protected $mAction;
35
36    /** @var string */
37    protected $mTarget;
38
39    /** @var string */
40    protected $mDest;
41
42    /** @var string */
43    protected $mTimestamp;
44
45    /** @var string */
46    protected $mTimestampOld;
47
48    /** @var int */
49    protected $mTargetID;
50
51    /** @var int */
52    protected $mDestID;
53
54    /** @var string */
55    protected $mComment;
56
57    /** @var bool Was posted? */
58    protected $mMerge;
59
60    /** @var bool Was submitted? */
61    protected $mSubmitted;
62
63    /** @var Title|null */
64    protected $mTargetObj;
65
66    /** @var Title|null */
67    protected $mDestObj;
68
69    private MergeHistoryFactory $mergeHistoryFactory;
70    private LinkBatchFactory $linkBatchFactory;
71    private IConnectionProvider $dbProvider;
72    private RevisionStore $revisionStore;
73    private CommentFormatter $commentFormatter;
74    private ChangeTagsStore $changeTagsStore;
75
76    /** @var Status */
77    private $mStatus;
78
79    public function __construct(
80        MergeHistoryFactory $mergeHistoryFactory,
81        LinkBatchFactory $linkBatchFactory,
82        IConnectionProvider $dbProvider,
83        RevisionStore $revisionStore,
84        CommentFormatter $commentFormatter,
85        ChangeTagsStore $changeTagsStore
86    ) {
87        parent::__construct( 'MergeHistory', 'mergehistory' );
88        $this->mergeHistoryFactory = $mergeHistoryFactory;
89        $this->linkBatchFactory = $linkBatchFactory;
90        $this->dbProvider = $dbProvider;
91        $this->revisionStore = $revisionStore;
92        $this->commentFormatter = $commentFormatter;
93        $this->changeTagsStore = $changeTagsStore;
94    }
95
96    /** @inheritDoc */
97    public function doesWrites() {
98        return true;
99    }
100
101    /**
102     * @return void
103     */
104    private function loadRequestParams() {
105        $request = $this->getRequest();
106        $this->mAction = $request->getRawVal( 'action' );
107        $this->mTarget = $request->getVal( 'target', '' );
108        $this->mDest = $request->getVal( 'dest', '' );
109        $this->mSubmitted = $request->getBool( 'submitted' );
110
111        $this->mTargetID = intval( $request->getVal( 'targetID' ) );
112        $this->mDestID = intval( $request->getVal( 'destID' ) );
113        $this->mTimestamp = $request->getVal( 'mergepoint' );
114        if ( $this->mTimestamp === null || !preg_match( '/[0-9]{14}(\|[0-9]+)?/', $this->mTimestamp ) ) {
115            $this->mTimestamp = '';
116        }
117        $this->mTimestampOld = $request->getVal( 'mergepointold' );
118        if ( $this->mTimestampOld === null || !preg_match( '/[0-9]{14}(\|[0-9]+)?/', $this->mTimestamp ) ) {
119            $this->mTimestampOld = '';
120        }
121        $this->mComment = $request->getText( 'wpComment' );
122
123        $this->mMerge = $request->wasPosted()
124            && $this->getContext()->getCsrfTokenSet()->matchToken( $request->getVal( 'wpEditToken' ) );
125
126        // target page
127        if ( $this->mSubmitted ) {
128            $this->mTargetObj = Title::newFromText( $this->mTarget );
129            $this->mDestObj = Title::newFromText( $this->mDest );
130        } else {
131            $this->mTargetObj = null;
132            $this->mDestObj = null;
133        }
134    }
135
136    /** @inheritDoc */
137    public function execute( $par ) {
138        $this->useTransactionalTimeLimit();
139
140        $this->checkPermissions();
141        $this->checkReadOnly();
142
143        $this->loadRequestParams();
144
145        $this->setHeaders();
146        $this->outputHeader();
147        $status = Status::newGood();
148
149        if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) {
150            $this->merge();
151
152            return;
153        }
154
155        if ( !$this->mSubmitted ) {
156            $this->showMergeForm();
157
158            return;
159        }
160
161        if ( !$this->mTargetObj instanceof Title ) {
162            $status->merge( Status::newFatal( 'mergehistory-invalid-source' ) );
163        } elseif ( !$this->mTargetObj->exists() ) {
164            $status->merge( Status::newFatal(
165                'mergehistory-no-source',
166                wfEscapeWikiText( $this->mTargetObj->getPrefixedText() )
167            ) );
168        }
169
170        if ( !$this->mDestObj instanceof Title ) {
171            $status->merge( Status::newFatal( 'mergehistory-invalid-destination' ) );
172        } elseif ( !$this->mDestObj->exists() ) {
173            $status->merge( Status::newFatal(
174                'mergehistory-no-destination',
175                wfEscapeWikiText( $this->mDestObj->getPrefixedText() )
176            ) );
177        }
178
179        if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) {
180            $status->merge( Status::newFatal( 'mergehistory-same-destination' ) );
181        }
182
183        $this->mStatus = $status;
184
185        $this->showMergeForm();
186
187        if ( $this->mStatus->isGood() ) {
188            $this->showHistory();
189        }
190    }
191
192    private function showMergeForm() {
193        $out = $this->getOutput();
194        $out->addWikiMsg( 'mergehistory-header' );
195
196        $fields = [
197            'submitted' => [
198                'type' => 'hidden',
199                'default' => '1',
200                'name' => 'submitted'
201            ],
202            'title' => [
203                'type' => 'hidden',
204                'default' => $this->getPageTitle()->getPrefixedDBkey(),
205                'name' => 'title'
206            ],
207            'mergepoint' => [
208                'type' => 'hidden',
209                'default' => $this->mTimestamp,
210                'name' => 'mergepoint'
211            ],
212            'mergepointold' => [
213                'type' => 'hidden',
214                'default' => $this->mTimestampOld,
215                'name' => 'mergepointold'
216            ],
217            'target' => [
218                'type' => 'title',
219                'label-message' => 'mergehistory-from',
220                'default' => $this->mTarget,
221                'id' => 'target',
222                'name' => 'target'
223            ],
224            'dest' => [
225                'type' => 'title',
226                'label-message' => 'mergehistory-into',
227                'default' => $this->mDest,
228                'id' => 'dest',
229                'name' => 'dest'
230            ]
231        ];
232
233        $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
234        $form->setWrapperLegendMsg( 'mergehistory-box' )
235            ->setSubmitTextMsg( 'mergehistory-go' )
236            ->setMethod( 'get' )
237            ->prepareForm()
238            ->displayForm( $this->mStatus );
239
240        $this->addHelpLink( 'Help:Merge history' );
241    }
242
243    private function showHistory() {
244        # List all stored revisions
245        $revisions = new MergeHistoryPager(
246            $this->getContext(),
247            $this->getLinkRenderer(),
248            $this->linkBatchFactory,
249            $this->dbProvider,
250            $this->revisionStore,
251            $this->commentFormatter,
252            $this->changeTagsStore,
253            [],
254            $this->mTargetObj,
255            $this->mDestObj,
256            $this->mTimestamp,
257            $this->mTimestampOld
258        );
259        $haveRevisions = $revisions->getNumRows() > 0;
260
261        $out = $this->getOutput();
262        $out->addModuleStyles( [
263            'mediawiki.interface.helpers.styles',
264            'mediawiki.special'
265        ] );
266        $out->addModules( 'mediawiki.special.mergeHistory' );
267        $titleObj = $this->getPageTitle();
268        $action = $titleObj->getLocalURL( [ 'action' => 'submit' ] );
269        # Start the form here
270        $fields = [
271            'targetID' => [
272                'type' => 'hidden',
273                'name' => 'targetID',
274                'default' => $this->mTargetObj->getArticleID()
275            ],
276            'destID' => [
277                'type' => 'hidden',
278                'name' => 'destID',
279                'default' => $this->mDestObj->getArticleID()
280            ],
281            'target' => [
282                'type' => 'hidden',
283                'name' => 'target',
284                'default' => $this->mTarget
285            ],
286            'dest' => [
287                'type' => 'hidden',
288                'name' => 'dest',
289                'default' => $this->mDest
290            ],
291        ];
292        if ( $haveRevisions ) {
293            $fields += [
294                'explanation' => [
295                    'type' => 'info',
296                    'default' => $this->msg( 'mergehistory-merge', $this->mTargetObj->getPrefixedText(),
297                        $this->mDestObj->getPrefixedText() )->parse(),
298                    'raw' => true,
299                    'cssclass' => 'mw-mergehistory-explanation',
300                    'section' => 'mergehistory-submit'
301                ],
302                'reason' => [
303                    'type' => 'text',
304                    'name' => 'wpComment',
305                    'label-message' => 'mergehistory-reason',
306                    'size' => 50,
307                    'default' => $this->mComment,
308                    'section' => 'mergehistory-submit'
309                ],
310                'submit' => [
311                    'type' => 'submit',
312                    'default' => $this->msg( 'mergehistory-submit' ),
313                    'section' => 'mergehistory-submit',
314                    'id' => 'mw-merge-submit',
315                    'name' => 'merge'
316                ]
317            ];
318        }
319        $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
320        $form->addHiddenField( 'wpEditToken', $form->getCsrfTokenSet()->getToken() )
321            ->setId( 'merge' )
322            ->setAction( $action )
323            ->suppressDefaultSubmit();
324
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()
331            );
332        } else {
333            $form->setFooterHtml( $this->msg( 'mergehistory-empty' )->escaped() );
334        }
335
336        $form->prepareForm()->displayForm( false );
337
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 );
342    }
343
344    /**
345     * Actually attempt the history move
346     *
347     * @todo if all versions of page A are moved to B and then a user
348     * tries to do a reverse-merge via the "unmerge" log link, then page
349     * A will still be a redirect (as it was after the original merge),
350     * though it will have the old revisions back from before (as expected).
351     * The user may have to "undo" the redirect manually to finish the "unmerge".
352     * Maybe this should delete redirects at the target page of merges?
353     *
354     * @return bool Success
355     */
356    private function merge() {
357        # Get the titles directly from the IDs, in case the target page params
358        # were spoofed. The queries are done based on the IDs, so it's best to
359        # keep it consistent...
360        $targetTitle = Title::newFromID( $this->mTargetID );
361        $destTitle = Title::newFromID( $this->mDestID );
362        if ( $targetTitle === null || $destTitle === null ) {
363            return false; // validate these
364        }
365        if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
366            return false;
367        }
368
369        // MergeHistory object
370        $mh = $this->mergeHistoryFactory->newMergeHistory(
371            $targetTitle,
372            $destTitle,
373            $this->mTimestamp,
374            $this->mTimestampOld
375        );
376
377        // Merge!
378        $mergeStatus = $mh->merge( $this->getAuthority(), $this->mComment );
379        if ( !$mergeStatus->isOK() ) {
380            // Failed merge
381            $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
382            return false;
383        }
384
385        $linkRenderer = $this->getLinkRenderer();
386
387        $targetLink = $linkRenderer->makeLink(
388            $targetTitle,
389            null,
390            [],
391            [ 'redirect' => 'no' ]
392        );
393
394        // In some cases the target page will be deleted
395        $append = ( $mergeStatus->getValue() === 'source-deleted' )
396            ? $this->msg( 'mergehistory-source-deleted', $targetTitle->getPrefixedText() ) : '';
397
398        $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
399            ->rawParams( $targetLink )
400            ->params( $destTitle->getPrefixedText(), $append )
401            ->numParams( $mh->getMergedRevisionCount() )
402        );
403
404        return true;
405    }
406
407    /** @inheritDoc */
408    protected function getGroupName() {
409        return 'pagetools';
410    }
411}
412
413/**
414 * Retain the old class name for backwards compatibility.
415 * @deprecated since 1.41
416 */
417class_alias( SpecialMergeHistory::class, 'SpecialMergeHistory' );