Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialMergeHistory.php
blobb21fcbaf163bb51a6f66c9720a03293ec2df3846
1 <?php
2 /**
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
18 * @file
21 namespace MediaWiki\Specials;
23 use LogEventsList;
24 use LogPage;
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;
36 /**
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
41 * merged.
43 * @ingroup SpecialPage
45 class SpecialMergeHistory extends SpecialPage {
46 /** @var string|null */
47 protected $mAction;
49 /** @var string */
50 protected $mTarget;
52 /** @var string */
53 protected $mDest;
55 /** @var string */
56 protected $mTimestamp;
58 /** @var int */
59 protected $mTargetID;
61 /** @var int */
62 protected $mDestID;
64 /** @var string */
65 protected $mComment;
67 /** @var bool Was posted? */
68 protected $mMerge;
70 /** @var bool Was submitted? */
71 protected $mSubmitted;
73 /** @var Title|null */
74 protected $mTargetObj;
76 /** @var Title|null */
77 protected $mDestObj;
79 private MergeHistoryFactory $mergeHistoryFactory;
80 private LinkBatchFactory $linkBatchFactory;
81 private IConnectionProvider $dbProvider;
82 private RevisionStore $revisionStore;
83 private CommentFormatter $commentFormatter;
85 /** @var Status */
86 private $mStatus;
88 /**
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() {
111 return true;
115 * @return void
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' ) );
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;
145 public function execute( $par ) {
146 $this->useTransactionalTimeLimit();
148 $this->checkPermissions();
149 $this->checkReadOnly();
151 $this->loadRequestParams();
153 $this->setHeaders();
154 $this->outputHeader();
155 $status = Status::newGood();
157 if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) {
158 $this->merge();
160 return;
163 if ( !$this->mSubmitted ) {
164 $this->showMergeForm();
166 return;
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 ) );
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 ) );
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' );
204 $fields = [
205 'submitted' => [
206 'type' => 'hidden',
207 'default' => '1',
208 'name' => 'submitted'
210 'title' => [
211 'type' => 'hidden',
212 'default' => $this->getPageTitle()->getPrefixedDBkey(),
213 'name' => 'title'
215 'mergepoint' => [
216 'type' => 'hidden',
217 'default' => $this->mTimestamp,
218 'name' => 'mergepoint'
220 'target' => [
221 'type' => 'title',
222 'label-message' => 'mergehistory-from',
223 'default' => $this->mTarget,
224 'id' => 'target',
225 'name' => 'target'
227 'dest' => [
228 'type' => 'title',
229 'label-message' => 'mergehistory-into',
230 'default' => $this->mDest,
231 'id' => 'dest',
232 'name' => 'dest'
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 );
243 $this->addHelpLink( 'Help:Merge history' );
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,
256 $this->mTargetObj,
257 $this->mDestObj,
258 $this->mTimestamp
260 $haveRevisions = $revisions->getNumRows() > 0;
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()
276 'destID' => [
277 'type' => 'hidden',
278 'name' => 'destID',
279 'default' => $this->mDestObj->getArticleID()
281 'target' => [
282 'type' => 'hidden',
283 'name' => 'target',
284 'default' => $this->mTarget
286 'dest' => [
287 'type' => 'hidden',
288 'name' => 'dest',
289 'default' => $this->mDest
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'
302 'reason' => [
303 'type' => 'text',
304 'name' => 'wpComment',
305 'label-message' => 'mergehistory-reason',
306 'size' => 50,
307 'default' => $this->mComment,
308 'section' => 'mergehistory-submit'
310 'submit' => [
311 'type' => 'submit',
312 'default' => $this->msg( 'mergehistory-submit' ),
313 'section' => 'mergehistory-submit',
314 'id' => 'mw-merge-submit',
315 'name' => 'merge'
319 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
320 $form->addHiddenField( 'wpEditToken', $form->getCsrfTokenSet()->getToken() )
321 ->setId( 'merge' )
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()
332 } else {
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 );
343 return true;
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() ) {
368 return false;
371 // MergeHistory object
372 $mh = $this->mergeHistoryFactory->newMergeHistory( $targetTitle, $destTitle, $this->mTimestamp );
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;
382 $linkRenderer = $this->getLinkRenderer();
384 $targetLink = $linkRenderer->makeLink(
385 $targetTitle,
386 null,
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() )
401 return true;
404 protected function getGroupName() {
405 return 'pagetools';
410 * Retain the old class name for backwards compatibility.
411 * @deprecated since 1.41
413 class_alias( SpecialMergeHistory::class, 'SpecialMergeHistory' );