MediaWiki  master
MergeHistory.php
Go to the documentation of this file.
1 <?php
2 
36 use Wikimedia\Timestamp\TimestampException;
37 
44 class MergeHistory {
45 
47  public const REVISION_LIMIT = 5000;
48 
50  protected $source;
51 
53  protected $dest;
54 
56  protected $dbw;
57 
59  protected $maxTimestamp;
60 
62  protected $timeWhere;
63 
65  protected $timestampLimit;
66 
68  protected $revisionsMerged;
69 
71  private $permManager;
72 
75 
77  private $revisionStore;
78 
81 
83  private $spamChecker;
84 
86  private $hookRunner;
87 
90 
107  public function __construct(
108  Title $source,
109  Title $dest,
110  $timestamp = false,
111  ILoadBalancer $loadBalancer = null,
116  SpamChecker $spamChecker = null,
117  HookContainer $hookContainer = null,
119  ) {
120  if ( $loadBalancer === null ) {
121  wfDeprecatedMsg( 'Direct construction of ' . __CLASS__ .
122  ' was deprecated in MediaWiki 1.35', '1.35' );
123  $services = MediaWikiServices::getInstance();
124 
125  $loadBalancer = $services->getDBLoadBalancer();
126  $permManager = $services->getPermissionManager();
127  $contentHandlerFactory = $services->getContentHandlerFactory();
128  $revisionStore = $services->getRevisionStore();
129  $watchedItemStore = $services->getWatchedItemStore();
130  $spamChecker = $services->getSpamChecker();
131  $hookContainer = $services->getHookContainer();
132  $wikiPageFactory = $services->getWikiPageFactory();
133  }
134 
135  // Save the parameters
136  $this->source = $source;
137  $this->dest = $dest;
138 
139  // Get the database
140  $this->dbw = $loadBalancer->getConnection( DB_MASTER );
141 
142  $this->permManager = $permManager;
143  $this->contentHandlerFactory = $contentHandlerFactory;
144  $this->revisionStore = $revisionStore;
145  $this->watchedItemStore = $watchedItemStore;
146  $this->spamChecker = $spamChecker;
147  $this->hookRunner = new HookRunner( $hookContainer );
148  $this->wikiPageFactory = $wikiPageFactory;
149 
150  // Max timestamp should be min of destination page
151  $firstDestTimestamp = $this->dbw->selectField(
152  'revision',
153  'MIN(rev_timestamp)',
154  [ 'rev_page' => $this->dest->getArticleID() ],
155  __METHOD__
156  );
157  $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
158 
159  // Get the timestamp pivot condition
160  try {
161  if ( $timestamp ) {
162  // If we have a requested timestamp, use the
163  // latest revision up to that point as the insertion point
164  $mwTimestamp = new MWTimestamp( $timestamp );
165  $lastWorkingTimestamp = $this->dbw->selectField(
166  'revision',
167  'MAX(rev_timestamp)',
168  [
169  'rev_timestamp <= ' .
170  $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
171  'rev_page' => $this->source->getArticleID()
172  ],
173  __METHOD__
174  );
175  $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
176 
177  $timeInsert = $mwLastWorkingTimestamp;
178  $this->timestampLimit = $mwLastWorkingTimestamp;
179  } else {
180  // If we don't, merge entire source page history into the
181  // beginning of destination page history
182 
183  // Get the latest timestamp of the source
184  $lastSourceTimestamp = $this->dbw->selectField(
185  [ 'page', 'revision' ],
186  'rev_timestamp',
187  [ 'page_id' => $this->source->getArticleID(),
188  'page_latest = rev_id'
189  ],
190  __METHOD__
191  );
192  $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
193 
194  $timeInsert = $this->maxTimestamp;
195  $this->timestampLimit = $lasttimestamp;
196  }
197 
198  $this->timeWhere = "rev_timestamp <= " .
199  $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
200  } catch ( TimestampException $ex ) {
201  // The timestamp we got is screwed up and merge cannot continue
202  // This should be detected by $this->isValidMerge()
203  $this->timestampLimit = false;
204  }
205  }
206 
211  public function getRevisionCount() {
212  $count = $this->dbw->selectRowCount( 'revision', '1',
213  [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
214  __METHOD__,
215  [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
216  );
217 
218  return $count;
219  }
220 
226  public function getMergedRevisionCount() {
227  return $this->revisionsMerged;
228  }
229 
236  public function checkPermissions( User $user, $reason ) {
237  $status = new Status();
238 
239  // Check if user can edit both pages
240  $errors = wfMergeErrorArrays(
241  $this->permManager->getPermissionErrors( 'edit', $user, $this->source ),
242  $this->permManager->getPermissionErrors( 'edit', $user, $this->dest )
243  );
244 
245  // Convert into a Status object
246  if ( $errors ) {
247  foreach ( $errors as $error ) {
248  $status->fatal( ...$error );
249  }
250  }
251 
252  // Anti-spam
253  if ( $this->spamChecker->checkSummary( $reason ) !== false ) {
254  // This is kind of lame, won't display nice
255  $status->fatal( 'spamprotectiontext' );
256  }
257 
258  // Check mergehistory permission
259  if ( !$this->permManager->userHasRight( $user, 'mergehistory' ) ) {
260  // User doesn't have the right to merge histories
261  $status->fatal( 'mergehistory-fail-permission' );
262  }
263 
264  return $status;
265  }
266 
274  public function isValidMerge() {
275  $status = new Status();
276 
277  // If either article ID is 0, then revisions cannot be reliably selected
278  if ( $this->source->getArticleID() === 0 ) {
279  $status->fatal( 'mergehistory-fail-invalid-source' );
280  }
281  if ( $this->dest->getArticleID() === 0 ) {
282  $status->fatal( 'mergehistory-fail-invalid-dest' );
283  }
284 
285  // Make sure page aren't the same
286  if ( $this->source->equals( $this->dest ) ) {
287  $status->fatal( 'mergehistory-fail-self-merge' );
288  }
289 
290  // Make sure the timestamp is valid
291  if ( !$this->timestampLimit ) {
292  $status->fatal( 'mergehistory-fail-bad-timestamp' );
293  }
294 
295  // $this->timestampLimit must be older than $this->maxTimestamp
296  if ( $this->timestampLimit > $this->maxTimestamp ) {
297  $status->fatal( 'mergehistory-fail-timestamps-overlap' );
298  }
299 
300  // Check that there are not too many revisions to move
301  if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
302  $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
303  }
304 
305  return $status;
306  }
307 
322  public function merge( User $user, $reason = '' ) {
323  $status = new Status();
324 
325  // Check validity and permissions required for merge
326  $validCheck = $this->isValidMerge(); // Check this first to check for null pages
327  if ( !$validCheck->isOK() ) {
328  return $validCheck;
329  }
330  $permCheck = $this->checkPermissions( $user, $reason );
331  if ( !$permCheck->isOK() ) {
332  return $permCheck;
333  }
334 
335  $this->dbw->startAtomic( __METHOD__ );
336 
337  $this->dbw->update(
338  'revision',
339  [ 'rev_page' => $this->dest->getArticleID() ],
340  [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
341  __METHOD__
342  );
343 
344  // Check if this did anything
345  $this->revisionsMerged = $this->dbw->affectedRows();
346  if ( $this->revisionsMerged < 1 ) {
347  $this->dbw->endAtomic( __METHOD__ );
348  $status->fatal( 'mergehistory-fail-no-change' );
349 
350  return $status;
351  }
352 
353  // Update denormalized revactor_page too
354  $this->dbw->update(
355  'revision_actor_temp',
356  [ 'revactor_page' => $this->dest->getArticleID() ],
357  [
358  'revactor_page' => $this->source->getArticleID(),
359  // Slightly hacky, but should work given the values assigned in this class
360  str_replace( 'rev_timestamp', 'revactor_timestamp', $this->timeWhere )
361  ],
362  __METHOD__
363  );
364 
365  $haveRevisions = $this->dbw->lockForUpdate(
366  'revision',
367  [ 'rev_page' => $this->source->getArticleID() ],
368  __METHOD__
369  );
370 
371  // Update source page, histories and invalidate caches
372  if ( !$haveRevisions ) {
373  if ( $reason ) {
374  $reason = wfMessage(
375  'mergehistory-comment',
376  $this->source->getPrefixedText(),
377  $this->dest->getPrefixedText(),
378  $reason
379  )->inContentLanguage()->text();
380  } else {
381  $reason = wfMessage(
382  'mergehistory-autocomment',
383  $this->source->getPrefixedText(),
384  $this->dest->getPrefixedText()
385  )->inContentLanguage()->text();
386  }
387 
388  $this->updateSourcePage( $status, $user, $reason );
389 
390  } else {
391  $this->source->invalidateCache();
392  }
393  $this->dest->invalidateCache();
394 
395  // Duplicate watchers of the old article to the new article
396  $this->watchedItemStore->duplicateAllAssociatedEntries( $this->source, $this->dest );
397 
398  // Update our logs
399  $logEntry = new ManualLogEntry( 'merge', 'merge' );
400  $logEntry->setPerformer( $user );
401  $logEntry->setComment( $reason );
402  $logEntry->setTarget( $this->source );
403  $logEntry->setParameters( [
404  '4::dest' => $this->dest->getPrefixedText(),
405  '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
406  ] );
407  $logId = $logEntry->insert();
408  $logEntry->publish( $logId );
409 
410  $this->hookRunner->onArticleMergeComplete( $this->source, $this->dest );
411 
412  $this->dbw->endAtomic( __METHOD__ );
413 
414  return $status;
415  }
416 
430  private function updateSourcePage( $status, $user, $reason ) {
431  $deleteSource = false;
432  $sourceModel = $this->source->getContentModel();
433  $contentHandler = $this->contentHandlerFactory->getContentHandler( $sourceModel );
434 
435  if ( !$contentHandler->supportsRedirects() ) {
436  $deleteSource = true;
437  $newContent = $contentHandler->makeEmptyContent();
438  } else {
439  $msg = wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain();
440  $newContent = $contentHandler->makeRedirectContent( $this->dest, $msg );
441  }
442 
443  if ( !$newContent instanceof Content ) {
444  // Handler supports redirect but cannot create new redirect content?
445  // Not possible to proceed without Content.
446 
447  // @todo. Remove this once there's no evidence it's happening or if it's
448  // determined all violating handlers have been fixed.
449  // This is mostly kept because previous code was also blindly checking
450  // existing of the Content for both content models that supports redirects
451  // and those that that don't, so it's hard to know what it was masking.
452  $logger = MediaWiki\Logger\LoggerFactory::getInstance( 'ContentHandler' );
453  $logger->warning(
454  'ContentHandler for {model} says it supports redirects but failed '
455  . 'to return Content object from ContentHandler::makeRedirectContent().'
456  . ' {value} returned instead.',
457  [
458  'value' => gettype( $newContent ),
459  'model' => $sourceModel
460  ]
461  );
462 
463  throw new InvalidArgumentException(
464  "ContentHandler for '$sourceModel' supports redirects" .
465  ' but cannot create redirect content during History merge.'
466  );
467  }
468 
469  // T263340/T93469: Create revision record to also serve as the page revision.
470  // This revision will be used to create page content. If the source page's
471  // content model supports redirects, then it will be the redirect content.
472  // If the content model does not supports redirect, this content will aid
473  // proper deletion of the page below.
474  $comment = CommentStoreComment::newUnsavedComment( $reason );
475  $revRecord = new MutableRevisionRecord( $this->source );
476  $revRecord->setContent( SlotRecord::MAIN, $newContent )
477  ->setPageId( $this->source->getArticleID() )
478  ->setComment( $comment )
479  ->setUser( $user )
480  ->setTimestamp( wfTimestampNow() );
481 
482  $insertedRevRecord = $this->revisionStore->insertRevisionOn( $revRecord, $this->dbw );
483 
484  $newPage = $this->wikiPageFactory->newFromTitle( $this->source );
485  $newPage->updateRevisionOn( $this->dbw, $insertedRevRecord );
486 
487  if ( !$deleteSource ) {
488  // We have created a redirect page so let's
489  // record the link from the page to the new title.
490  // It should have no other outgoing links...
491  $this->dbw->delete(
492  'pagelinks',
493  [ 'pl_from' => $this->dest->getArticleID() ],
494  __METHOD__
495  );
496  $this->dbw->insert( 'pagelinks',
497  [
498  'pl_from' => $this->dest->getArticleID(),
499  'pl_from_namespace' => $this->dest->getNamespace(),
500  'pl_namespace' => $this->dest->getNamespace(),
501  'pl_title' => $this->dest->getDBkey() ],
502  __METHOD__
503  );
504 
505  } else {
506  // T263340/T93469: Delete the source page to prevent errors because its
507  // revisions are now tied to a different title and its content model
508  // does not support redirects, so we cannot leave a new revision on it.
509  // This deletion does not depend on userright but may still fails. If it
510  // fails, it will be communicated in the status reponse.
511  $reason = wfMessage( 'mergehistory-source-deleted-reason' )->inContentLanguage()->plain();
512  $deletionStatus = $newPage->doDeleteArticleReal( $reason, $user );
513  $status->merge( $deletionStatus );
514  }
515 
516  return $status;
517  }
518 }
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:34
MergeHistory\__construct
__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, HookContainer $hookContainer=null, WikiPageFactory $wikiPageFactory=null)
Since 1.35 dependencies are injected and not providing them is hard deprecated; use the MergeHistoryF...
Definition: MergeHistory.php:107
Message\numParam
static numParam( $num)
Definition: Message.php:1038
CommentStoreComment\newUnsavedComment
static newUnsavedComment( $comment, array $data=null)
Create a new, unsaved CommentStoreComment.
Definition: CommentStoreComment.php:67
wfMergeErrorArrays
wfMergeErrorArrays(... $args)
Merge arrays in the style of PermissionManager::getPermissionErrors, with duplicate removal e....
Definition: GlobalFunctions.php:184
MergeHistory\checkPermissions
checkPermissions(User $user, $reason)
Check if the merge is possible.
Definition: MergeHistory.php:236
MergeHistory\$watchedItemStore
WatchedItemStoreInterface $watchedItemStore
Definition: MergeHistory.php:80
MediaWiki\MediaWikiServices
MediaWikiServices is the service locator for the application scope of MediaWiki.
Definition: MediaWikiServices.php:172
MergeHistory\$revisionsMerged
int $revisionsMerged
Number of revisions merged (for Special:MergeHistory success message)
Definition: MergeHistory.php:68
Revision\RevisionStore
Service for looking up page revisions.
Definition: RevisionStore.php:84
MediaWiki\Logger\LoggerFactory\getInstance
static getInstance( $channel)
Get a named logger instance from the currently configured logger factory.
Definition: LoggerFactory.php:92
MergeHistory\$dbw
IDatabase $dbw
Database that we are using.
Definition: MergeHistory.php:56
MergeHistory\getMergedRevisionCount
getMergedRevisionCount()
Get the number of revisions that were moved Used in the SpecialMergeHistory success message.
Definition: MergeHistory.php:226
MergeHistory\$spamChecker
SpamChecker $spamChecker
Definition: MergeHistory.php:83
wfMessage
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Definition: GlobalFunctions.php:1231
MergeHistory\$contentHandlerFactory
IContentHandlerFactory $contentHandlerFactory
Definition: MergeHistory.php:74
Wikimedia\Rdbms\IDatabase
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:38
Status
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:44
wfDeprecatedMsg
wfDeprecatedMsg( $msg, $version=false, $component=false, $callerOffset=2)
Log a deprecation warning with arbitrary message text.
Definition: GlobalFunctions.php:1066
MergeHistory\$revisionStore
RevisionStore $revisionStore
Definition: MergeHistory.php:77
MergeHistory\$permManager
PermissionManager $permManager
Definition: MergeHistory.php:71
Page\WikiPageFactory
Definition: WikiPageFactory.php:20
MergeHistory\$timestampLimit
MWTimestamp bool $timestampLimit
Timestamp upto which history from the source will be merged.
Definition: MergeHistory.php:65
wfTimestampNow
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
Definition: GlobalFunctions.php:1861
DB_MASTER
const DB_MASTER
Definition: defines.php:26
MergeHistory\$hookRunner
HookRunner $hookRunner
Definition: MergeHistory.php:86
MergeHistory\$maxTimestamp
MWTimestamp $maxTimestamp
Maximum timestamp that we can use (oldest timestamp of dest)
Definition: MergeHistory.php:59
MediaWiki\Permissions\PermissionManager
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Definition: PermissionManager.php:51
MediaWiki\Content\IContentHandlerFactory
Definition: IContentHandlerFactory.php:10
Revision\MutableRevisionRecord
Definition: MutableRevisionRecord.php:45
MediaWiki\EditPage\SpamChecker
Service to check if text (either content or a summary) qualifies as spam.
Definition: SpamChecker.php:14
MergeHistory\REVISION_LIMIT
const REVISION_LIMIT
Maximum number of revisions that can be merged at once.
Definition: MergeHistory.php:47
MergeHistory\merge
merge(User $user, $reason='')
Actually attempt the history move.
Definition: MergeHistory.php:322
MergeHistory\$timeWhere
string $timeWhere
SQL WHERE condition that selects source revisions to insert into destination.
Definition: MergeHistory.php:62
MergeHistory
Handles the backend logic of merging the histories of two pages.
Definition: MergeHistory.php:44
MergeHistory\$source
Title $source
Page from which history will be merged.
Definition: MergeHistory.php:50
MergeHistory\isValidMerge
isValidMerge()
Does various sanity checks that the merge is valid.
Definition: MergeHistory.php:274
Content
Base interface for content objects.
Definition: Content.php:35
Title
Represents a title within MediaWiki.
Definition: Title.php:47
MergeHistory\updateSourcePage
updateSourcePage( $status, $user, $reason)
Do various cleanup work and updates to the source page.
Definition: MergeHistory.php:430
MergeHistory\$dest
Title $dest
Page to which history will be merged.
Definition: MergeHistory.php:53
ManualLogEntry
Class for creating new log entries and inserting them into the database.
Definition: ManualLogEntry.php:43
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:575
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:30
User
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition: User.php:66
MergeHistory\getRevisionCount
getRevisionCount()
Get the number of revisions that will be moved.
Definition: MergeHistory.php:211
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
MergeHistory\$wikiPageFactory
WikiPageFactory $wikiPageFactory
Definition: MergeHistory.php:89
Revision\SlotRecord
Value object representing a content slot associated with a page revision.
Definition: SlotRecord.php:40