Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 212
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 / 211
0.00% covered (danger)
0.00%
0 / 8
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
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 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 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 / 40
0.00% covered (danger)
0.00%
0 / 1
2
 showHistory
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
12
 merge
0.00% covered (danger)
0.00%
0 / 26
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 * 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
24namespace MediaWiki\Specials;
25
26use LogEventsList;
27use LogPage;
28use MediaWiki\Cache\LinkBatchFactory;
29use MediaWiki\CommentFormatter\CommentFormatter;
30use MediaWiki\HTMLForm\HTMLForm;
31use MediaWiki\Page\MergeHistoryFactory;
32use MediaWiki\Pager\MergeHistoryPager;
33use MediaWiki\Revision\RevisionStore;
34use MediaWiki\SpecialPage\SpecialPage;
35use MediaWiki\Status\Status;
36use MediaWiki\Title\Title;
37use Wikimedia\Rdbms\IConnectionProvider;
38
39/**
40 * Special page allowing users with the appropriate permissions to
41 * merge article histories, with some restrictions
42 *
43 * @ingroup SpecialPage
44 */
45class SpecialMergeHistory extends SpecialPage {
46    /** @var string */
47    protected $mAction;
48
49    /** @var string */
50    protected $mTarget;
51
52    /** @var string */
53    protected $mDest;
54
55    /** @var string */
56    protected $mTimestamp;
57
58    /** @var int */
59    protected $mTargetID;
60
61    /** @var int */
62    protected $mDestID;
63
64    /** @var string */
65    protected $mComment;
66
67    /** @var bool Was posted? */
68    protected $mMerge;
69
70    /** @var bool Was submitted? */
71    protected $mSubmitted;
72
73    /** @var Title|null */
74    protected $mTargetObj;
75
76    /** @var Title|null */
77    protected $mDestObj;
78
79    private MergeHistoryFactory $mergeHistoryFactory;
80    private LinkBatchFactory $linkBatchFactory;
81    private IConnectionProvider $dbProvider;
82    private RevisionStore $revisionStore;
83    private CommentFormatter $commentFormatter;
84
85    /** @var Status */
86    private $mStatus;
87
88    /**
89     * @param MergeHistoryFactory $mergeHistoryFactory
90     * @param LinkBatchFactory $linkBatchFactory
91     * @param IConnectionProvider $dbProvider
92     * @param RevisionStore $revisionStore
93     * @param CommentFormatter $commentFormatter
94     */
95    public function __construct(
96        MergeHistoryFactory $mergeHistoryFactory,
97        LinkBatchFactory $linkBatchFactory,
98        IConnectionProvider $dbProvider,
99        RevisionStore $revisionStore,
100        CommentFormatter $commentFormatter
101    ) {
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;
108    }
109
110    public function doesWrites() {
111        return true;
112    }
113
114    /**
115     * @return void
116     */
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' );
123
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 = '';
129        }
130        $this->mComment = $request->getText( 'wpComment' );
131
132        $this->mMerge = $request->wasPosted()
133            && $this->getContext()->getCsrfTokenSet()->matchToken( $request->getVal( 'wpEditToken' ) );
134
135        // target page
136        if ( $this->mSubmitted ) {
137            $this->mTargetObj = Title::newFromText( $this->mTarget );
138            $this->mDestObj = Title::newFromText( $this->mDest );
139        } else {
140            $this->mTargetObj = null;
141            $this->mDestObj = null;
142        }
143    }
144
145    public function execute( $par ) {
146        $this->useTransactionalTimeLimit();
147
148        $this->checkPermissions();
149        $this->checkReadOnly();
150
151        $this->loadRequestParams();
152
153        $this->setHeaders();
154        $this->outputHeader();
155        $status = Status::newGood();
156
157        if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) {
158            $this->merge();
159
160            return;
161        }
162
163        if ( !$this->mSubmitted ) {
164            $this->showMergeForm();
165
166            return;
167        }
168
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() )
175            ) );
176        }
177
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() )
184            ) );
185        }
186
187        if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) {
188            $status->merge( Status::newFatal( 'mergehistory-same-destination' ) );
189        }
190
191        $this->mStatus = $status;
192
193        $this->showMergeForm();
194
195        if ( $this->mStatus->isGood() ) {
196            $this->showHistory();
197        }
198    }
199
200    private function showMergeForm() {
201        $out = $this->getOutput();
202        $out->addWikiMsg( 'mergehistory-header' );
203
204        $fields = [
205            'submitted' => [
206                'type' => 'hidden',
207                'default' => '1',
208                'name' => 'submitted'
209            ],
210            'title' => [
211                'type' => 'hidden',
212                'default' => $this->getPageTitle()->getPrefixedDBkey(),
213                'name' => 'title'
214            ],
215            'mergepoint' => [
216                'type' => 'hidden',
217                'default' => $this->mTimestamp,
218                'name' => 'mergepoint'
219            ],
220            'target' => [
221                'type' => 'title',
222                'label-message' => 'mergehistory-from',
223                'default' => $this->mTarget,
224                'id' => 'target',
225                'name' => 'target'
226            ],
227            'dest' => [
228                'type' => 'title',
229                'label-message' => 'mergehistory-into',
230                'default' => $this->mDest,
231                'id' => 'dest',
232                'name' => 'dest'
233            ]
234        ];
235
236        $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
237        $form->setWrapperLegendMsg( 'mergehistory-box' )
238            ->setSubmitTextMsg( 'mergehistory-go' )
239            ->setMethod( 'get' )
240            ->prepareForm()
241            ->displayForm( $this->mStatus );
242
243        $this->addHelpLink( 'Help:Merge history' );
244    }
245
246    private function showHistory() {
247        # List all stored revisions
248        $revisions = new MergeHistoryPager(
249            $this->getContext(),
250            $this->getLinkRenderer(),
251            $this->linkBatchFactory,
252            $this->dbProvider,
253            $this->revisionStore,
254            $this->commentFormatter,
255            [],
256            $this->mTargetObj,
257            $this->mDestObj,
258            $this->mTimestamp
259        );
260        $haveRevisions = $revisions->getNumRows() > 0;
261
262        $out = $this->getOutput();
263        $out->addModuleStyles( [
264            'mediawiki.interface.helpers.styles',
265            'mediawiki.special'
266        ] );
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' ) );
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        return true;
344    }
345
346    /**
347     * Actually attempt the history move
348     *
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?
355     *
356     * @return bool Success
357     */
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
366        }
367        if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
368            return false;
369        }
370
371        // MergeHistory object
372        $mh = $this->mergeHistoryFactory->newMergeHistory( $targetTitle, $destTitle, $this->mTimestamp );
373
374        // Merge!
375        $mergeStatus = $mh->merge( $this->getAuthority(), $this->mComment );
376        if ( !$mergeStatus->isOK() ) {
377            // Failed merge
378            $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
379            return false;
380        }
381
382        $linkRenderer = $this->getLinkRenderer();
383
384        $targetLink = $linkRenderer->makeLink(
385            $targetTitle,
386            null,
387            [],
388            [ 'redirect' => 'no' ]
389        );
390
391        // In some cases the target page will be deleted
392        $append = ( $mergeStatus->getValue() === 'source-deleted' )
393            ? $this->msg( 'mergehistory-source-deleted', $targetTitle->getPrefixedText() ) : '';
394
395        $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
396            ->rawParams( $targetLink )
397            ->params( $destTitle->getPrefixedText(), $append )
398            ->numParams( $mh->getMergedRevisionCount() )
399        );
400
401        return true;
402    }
403
404    protected function getGroupName() {
405        return 'pagetools';
406    }
407}
408
409/**
410 * Retain the old class name for backwards compatibility.
411 * @deprecated since 1.41
412 */
413class_alias( SpecialMergeHistory::class, 'SpecialMergeHistory' );