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