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