MediaWiki REL1_35
MergeHistory.php
Go to the documentation of this file.
1<?php
2
33use Wikimedia\Timestamp\TimestampException;
34
42
44 public const REVISION_LIMIT = 5000;
45
47 protected $source;
48
50 protected $dest;
51
53 protected $dbw;
54
56 protected $maxTimestamp;
57
59 protected $timeWhere;
60
62 protected $timestampLimit;
63
66
68 private $permManager;
69
72
75
78
80 private $spamChecker;
81
96 public function __construct(
98 Title $dest,
99 $timestamp = false,
100 ILoadBalancer $loadBalancer = null,
101 PermissionManager $permManager = null,
102 IContentHandlerFactory $contentHandlerFactory = null,
103 RevisionStore $revisionStore = null,
104 WatchedItemStoreInterface $watchedItemStore = null,
105 SpamChecker $spamChecker = null
106 ) {
107 if ( $loadBalancer === null ) {
108 wfDeprecatedMsg( 'Direct construction of ' . __CLASS__ .
109 ' was deprecated in MediaWiki 1.35', '1.35' );
110 $services = MediaWikiServices::getInstance();
111
112 $loadBalancer = $services->getDBLoadBalancer();
113 $permManager = $services->getPermissionManager();
114 $contentHandlerFactory = $services->getContentHandlerFactory();
115 $revisionStore = $services->getRevisionStore();
116 $watchedItemStore = $services->getWatchedItemStore();
117 $spamChecker = $services->getSpamChecker();
118 }
119
120 // Save the parameters
121 $this->source = $source;
122 $this->dest = $dest;
123
124 // Get the database
125 $this->dbw = $loadBalancer->getConnection( DB_MASTER );
126
127 $this->permManager = $permManager;
128 $this->contentHandlerFactory = $contentHandlerFactory;
129 $this->revisionStore = $revisionStore;
130 $this->watchedItemStore = $watchedItemStore;
131 $this->spamChecker = $spamChecker;
132
133 // Max timestamp should be min of destination page
134 $firstDestTimestamp = $this->dbw->selectField(
135 'revision',
136 'MIN(rev_timestamp)',
137 [ 'rev_page' => $this->dest->getArticleID() ],
138 __METHOD__
139 );
140 $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
141
142 // Get the timestamp pivot condition
143 try {
144 if ( $timestamp ) {
145 // If we have a requested timestamp, use the
146 // latest revision up to that point as the insertion point
147 $mwTimestamp = new MWTimestamp( $timestamp );
148 $lastWorkingTimestamp = $this->dbw->selectField(
149 'revision',
150 'MAX(rev_timestamp)',
151 [
152 'rev_timestamp <= ' .
153 $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
154 'rev_page' => $this->source->getArticleID()
155 ],
156 __METHOD__
157 );
158 $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
159
160 $timeInsert = $mwLastWorkingTimestamp;
161 $this->timestampLimit = $mwLastWorkingTimestamp;
162 } else {
163 // If we don't, merge entire source page history into the
164 // beginning of destination page history
165
166 // Get the latest timestamp of the source
167 $lastSourceTimestamp = $this->dbw->selectField(
168 [ 'page', 'revision' ],
169 'rev_timestamp',
170 [ 'page_id' => $this->source->getArticleID(),
171 'page_latest = rev_id'
172 ],
173 __METHOD__
174 );
175 $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
176
177 $timeInsert = $this->maxTimestamp;
178 $this->timestampLimit = $lasttimestamp;
179 }
180
181 $this->timeWhere = "rev_timestamp <= " .
182 $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
183 } catch ( TimestampException $ex ) {
184 // The timestamp we got is screwed up and merge cannot continue
185 // This should be detected by $this->isValidMerge()
186 $this->timestampLimit = false;
187 }
188 }
189
194 public function getRevisionCount() {
195 $count = $this->dbw->selectRowCount( 'revision', '1',
196 [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
197 __METHOD__,
198 [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
199 );
200
201 return $count;
202 }
203
209 public function getMergedRevisionCount() {
210 return $this->revisionsMerged;
211 }
212
219 public function checkPermissions( User $user, $reason ) {
220 $status = new Status();
221
222 // Check if user can edit both pages
223 $errors = wfMergeErrorArrays(
224 $this->permManager->getPermissionErrors( 'edit', $user, $this->source ),
225 $this->permManager->getPermissionErrors( 'edit', $user, $this->dest )
226 );
227
228 // Convert into a Status object
229 if ( $errors ) {
230 foreach ( $errors as $error ) {
231 $status->fatal( ...$error );
232 }
233 }
234
235 // Anti-spam
236 if ( $this->spamChecker->checkSummary( $reason ) !== false ) {
237 // This is kind of lame, won't display nice
238 $status->fatal( 'spamprotectiontext' );
239 }
240
241 // Check mergehistory permission
242 if ( !$this->permManager->userHasRight( $user, 'mergehistory' ) ) {
243 // User doesn't have the right to merge histories
244 $status->fatal( 'mergehistory-fail-permission' );
245 }
246
247 return $status;
248 }
249
257 public function isValidMerge() {
258 $status = new Status();
259
260 // If either article ID is 0, then revisions cannot be reliably selected
261 if ( $this->source->getArticleID() === 0 ) {
262 $status->fatal( 'mergehistory-fail-invalid-source' );
263 }
264 if ( $this->dest->getArticleID() === 0 ) {
265 $status->fatal( 'mergehistory-fail-invalid-dest' );
266 }
267
268 // Make sure page aren't the same
269 if ( $this->source->equals( $this->dest ) ) {
270 $status->fatal( 'mergehistory-fail-self-merge' );
271 }
272
273 // Make sure the timestamp is valid
274 if ( !$this->timestampLimit ) {
275 $status->fatal( 'mergehistory-fail-bad-timestamp' );
276 }
277
278 // $this->timestampLimit must be older than $this->maxTimestamp
279 if ( $this->timestampLimit > $this->maxTimestamp ) {
280 $status->fatal( 'mergehistory-fail-timestamps-overlap' );
281 }
282
283 // Check that there are not too many revisions to move
284 if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
285 $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
286 }
287
288 return $status;
289 }
290
305 public function merge( User $user, $reason = '' ) {
306 $status = new Status();
307
308 // Check validity and permissions required for merge
309 $validCheck = $this->isValidMerge(); // Check this first to check for null pages
310 if ( !$validCheck->isOK() ) {
311 return $validCheck;
312 }
313 $permCheck = $this->checkPermissions( $user, $reason );
314 if ( !$permCheck->isOK() ) {
315 return $permCheck;
316 }
317
318 $this->dbw->startAtomic( __METHOD__ );
319
320 $this->dbw->update(
321 'revision',
322 [ 'rev_page' => $this->dest->getArticleID() ],
323 [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
324 __METHOD__
325 );
326
327 // Check if this did anything
328 $this->revisionsMerged = $this->dbw->affectedRows();
329 if ( $this->revisionsMerged < 1 ) {
330 $this->dbw->endAtomic( __METHOD__ );
331 $status->fatal( 'mergehistory-fail-no-change' );
332
333 return $status;
334 }
335
336 // Update denormalized revactor_page too
337 $this->dbw->update(
338 'revision_actor_temp',
339 [ 'revactor_page' => $this->dest->getArticleID() ],
340 [
341 'revactor_page' => $this->source->getArticleID(),
342 // Slightly hacky, but should work given the values assigned in this class
343 str_replace( 'rev_timestamp', 'revactor_timestamp', $this->timeWhere )
344 ],
345 __METHOD__
346 );
347
348 // Make the source page a redirect if no revisions are left
349 $haveRevisions = $this->dbw->lockForUpdate(
350 'revision',
351 [ 'rev_page' => $this->source->getArticleID() ],
352 __METHOD__
353 );
354
355 if ( !$haveRevisions ) {
356 if ( $reason ) {
357 $reason = wfMessage(
358 'mergehistory-comment',
359 $this->source->getPrefixedText(),
360 $this->dest->getPrefixedText(),
361 $reason
362 )->inContentLanguage()->text();
363 } else {
364 $reason = wfMessage(
365 'mergehistory-autocomment',
366 $this->source->getPrefixedText(),
367 $this->dest->getPrefixedText()
368 )->inContentLanguage()->text();
369 }
370
371 $redirectContent = $this->contentHandlerFactory
372 ->getContentHandler( $this->source->getContentModel() )
373 ->makeRedirectContent(
374 $this->dest,
375 wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
376 );
377
378 if ( $redirectContent ) {
379 $redirectComment = CommentStoreComment::newUnsavedComment( $reason );
380
381 $redirectRevRecord = new MutableRevisionRecord( $this->source );
382 $redirectRevRecord->setContent( SlotRecord::MAIN, $redirectContent );
383 $redirectRevRecord->setPageId( $this->source->getArticleID() );
384 $redirectRevRecord->setComment( $redirectComment );
385 $redirectRevRecord->setUser( $user );
386 $redirectRevRecord->setTimestamp( wfTimestampNow() );
387
388 $insertedRevRecord = $this->revisionStore->insertRevisionOn(
389 $redirectRevRecord,
390 $this->dbw
391 );
392
393 $redirectPage = WikiPage::factory( $this->source );
394 $redirectPage->updateRevisionOn( $this->dbw, $insertedRevRecord );
395
396 // Now, we record the link from the redirect to the new title.
397 // It should have no other outgoing links...
398 $this->dbw->delete(
399 'pagelinks',
400 [ 'pl_from' => $this->dest->getArticleID() ],
401 __METHOD__
402 );
403 $this->dbw->insert( 'pagelinks',
404 [
405 'pl_from' => $this->dest->getArticleID(),
406 'pl_from_namespace' => $this->dest->getNamespace(),
407 'pl_namespace' => $this->dest->getNamespace(),
408 'pl_title' => $this->dest->getDBkey() ],
409 __METHOD__
410 );
411 } else {
412 // Warning if we couldn't create the redirect
413 $status->warning( 'mergehistory-warning-redirect-not-created' );
414 }
415 } else {
416 $this->source->invalidateCache(); // update histories
417 }
418 $this->dest->invalidateCache(); // update histories
419
420 // Duplicate watchers of the old article to the new article on history merge
421 $this->watchedItemStore->duplicateAllAssociatedEntries( $this->source, $this->dest );
422
423 // Update our logs
424 $logEntry = new ManualLogEntry( 'merge', 'merge' );
425 $logEntry->setPerformer( $user );
426 $logEntry->setComment( $reason );
427 $logEntry->setTarget( $this->source );
428 $logEntry->setParameters( [
429 '4::dest' => $this->dest->getPrefixedText(),
430 '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
431 ] );
432 $logId = $logEntry->insert();
433 $logEntry->publish( $logId );
434
435 Hooks::runner()->onArticleMergeComplete( $this->source, $this->dest );
436
437 $this->dbw->endAtomic( __METHOD__ );
438
439 return $status;
440 }
441}
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfMergeErrorArrays(... $args)
Merge arrays in the style of PermissionManager::getPermissionErrors, with duplicate removal e....
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Library for creating and parsing MW-style timestamps.
Class for creating new log entries and inserting them into the database.
Service to check if text (either content or a summary) qualifies as spam.
MediaWikiServices is the service locator for the application scope of MediaWiki.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
Handles the backend logic of merging the histories of two pages.
getMergedRevisionCount()
Get the number of revisions that were moved Used in the SpecialMergeHistory success message.
Title $source
Page from which history will be merged.
MWTimestamp $maxTimestamp
Maximum timestamp that we can use (oldest timestamp of dest)
merge(User $user, $reason='')
Actually attempt the history move.
int $revisionsMerged
Number of revisions merged (for Special:MergeHistory success message)
PermissionManager $permManager
Title $dest
Page to which history will be merged.
MWTimestamp bool $timestampLimit
Timestamp upto which history from the source will be merged.
WatchedItemStoreInterface $watchedItemStore
IDatabase $dbw
Database that we are using.
RevisionStore $revisionStore
getRevisionCount()
Get the number of revisions that will be moved.
__construct(Title $source, Title $dest, $timestamp=false, ILoadBalancer $loadBalancer=null, PermissionManager $permManager=null, IContentHandlerFactory $contentHandlerFactory=null, RevisionStore $revisionStore=null, WatchedItemStoreInterface $watchedItemStore=null, SpamChecker $spamChecker=null)
Since 1.35 dependencies are injected and not providing them is hard deprecated; use the MergeHistoryF...
IContentHandlerFactory $contentHandlerFactory
string $timeWhere
SQL WHERE condition that selects source revisions to insert into destination.
SpamChecker $spamChecker
const REVISION_LIMIT
Maximum number of revisions that can be merged at once.
isValidMerge()
Does various sanity checks that the merge is valid.
checkPermissions(User $user, $reason)
Check if the merge is possible.
static numParam( $num)
Definition Message.php:1064
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Represents a title within MediaWiki.
Definition Title.php:42
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:60
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
Database cluster connection, tracking, load balancing, and transaction manager interface.
$source
const DB_MASTER
Definition defines.php:29