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