MediaWiki  master
MergeHistory.php
Go to the documentation of this file.
1 <?php
2 
26 
33 class MergeHistory {
34 
36  const REVISION_LIMIT = 5000;
37 
39  protected $source;
40 
42  protected $dest;
43 
45  protected $dbw;
46 
48  protected $maxTimestamp;
49 
51  protected $timeWhere;
52 
54  protected $timestampLimit;
55 
57  protected $revisionsMerged;
58 
64  public function __construct( Title $source, Title $dest, $timestamp = false ) {
65  // Save the parameters
66  $this->source = $source;
67  $this->dest = $dest;
68 
69  // Get the database
70  $this->dbw = wfGetDB( DB_MASTER );
71 
72  // Max timestamp should be min of destination page
73  $firstDestTimestamp = $this->dbw->selectField(
74  'revision',
75  'MIN(rev_timestamp)',
76  [ 'rev_page' => $this->dest->getArticleID() ],
77  __METHOD__
78  );
79  $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
80 
81  // Get the timestamp pivot condition
82  try {
83  if ( $timestamp ) {
84  // If we have a requested timestamp, use the
85  // latest revision up to that point as the insertion point
86  $mwTimestamp = new MWTimestamp( $timestamp );
87  $lastWorkingTimestamp = $this->dbw->selectField(
88  'revision',
89  'MAX(rev_timestamp)',
90  [
91  'rev_timestamp <= ' .
92  $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
93  'rev_page' => $this->source->getArticleID()
94  ],
95  __METHOD__
96  );
97  $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
98 
99  $timeInsert = $mwLastWorkingTimestamp;
100  $this->timestampLimit = $mwLastWorkingTimestamp;
101  } else {
102  // If we don't, merge entire source page history into the
103  // beginning of destination page history
104 
105  // Get the latest timestamp of the source
106  $lastSourceTimestamp = $this->dbw->selectField(
107  [ 'page', 'revision' ],
108  'rev_timestamp',
109  [ 'page_id' => $this->source->getArticleID(),
110  'page_latest = rev_id'
111  ],
112  __METHOD__
113  );
114  $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
115 
116  $timeInsert = $this->maxTimestamp;
117  $this->timestampLimit = $lasttimestamp;
118  }
119 
120  $this->timeWhere = "rev_timestamp <= " .
121  $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
122  } catch ( TimestampException $ex ) {
123  // The timestamp we got is screwed up and merge cannot continue
124  // This should be detected by $this->isValidMerge()
125  $this->timestampLimit = false;
126  }
127  }
128 
133  public function getRevisionCount() {
134  $count = $this->dbw->selectRowCount( 'revision', '1',
135  [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
136  __METHOD__,
137  [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
138  );
139 
140  return $count;
141  }
142 
148  public function getMergedRevisionCount() {
149  return $this->revisionsMerged;
150  }
151 
158  public function checkPermissions( User $user, $reason ) {
159  $status = new Status();
160 
161  // Check if user can edit both pages
162  $errors = wfMergeErrorArrays(
163  $this->source->getUserPermissionsErrors( 'edit', $user ),
164  $this->dest->getUserPermissionsErrors( 'edit', $user )
165  );
166 
167  // Convert into a Status object
168  if ( $errors ) {
169  foreach ( $errors as $error ) {
170  $status->fatal( ...$error );
171  }
172  }
173 
174  // Anti-spam
175  if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
176  // This is kind of lame, won't display nice
177  $status->fatal( 'spamprotectiontext' );
178  }
179 
180  // Check mergehistory permission
181  $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
182  if ( !$permissionManager->userHasRight( $user, 'mergehistory' ) ) {
183  // User doesn't have the right to merge histories
184  $status->fatal( 'mergehistory-fail-permission' );
185  }
186 
187  return $status;
188  }
189 
197  public function isValidMerge() {
198  $status = new Status();
199 
200  // If either article ID is 0, then revisions cannot be reliably selected
201  if ( $this->source->getArticleID() === 0 ) {
202  $status->fatal( 'mergehistory-fail-invalid-source' );
203  }
204  if ( $this->dest->getArticleID() === 0 ) {
205  $status->fatal( 'mergehistory-fail-invalid-dest' );
206  }
207 
208  // Make sure page aren't the same
209  if ( $this->source->equals( $this->dest ) ) {
210  $status->fatal( 'mergehistory-fail-self-merge' );
211  }
212 
213  // Make sure the timestamp is valid
214  if ( !$this->timestampLimit ) {
215  $status->fatal( 'mergehistory-fail-bad-timestamp' );
216  }
217 
218  // $this->timestampLimit must be older than $this->maxTimestamp
219  if ( $this->timestampLimit > $this->maxTimestamp ) {
220  $status->fatal( 'mergehistory-fail-timestamps-overlap' );
221  }
222 
223  // Check that there are not too many revisions to move
224  if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
225  $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
226  }
227 
228  return $status;
229  }
230 
245  public function merge( User $user, $reason = '' ) {
246  $status = new Status();
247 
248  // Check validity and permissions required for merge
249  $validCheck = $this->isValidMerge(); // Check this first to check for null pages
250  if ( !$validCheck->isOK() ) {
251  return $validCheck;
252  }
253  $permCheck = $this->checkPermissions( $user, $reason );
254  if ( !$permCheck->isOK() ) {
255  return $permCheck;
256  }
257 
258  $this->dbw->startAtomic( __METHOD__ );
259 
260  $this->dbw->update(
261  'revision',
262  [ 'rev_page' => $this->dest->getArticleID() ],
263  [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
264  __METHOD__
265  );
266 
267  // Check if this did anything
268  $this->revisionsMerged = $this->dbw->affectedRows();
269  if ( $this->revisionsMerged < 1 ) {
270  $this->dbw->endAtomic( __METHOD__ );
271  $status->fatal( 'mergehistory-fail-no-change' );
272 
273  return $status;
274  }
275 
276  // Update denormalized revactor_page too
277  $this->dbw->update(
278  'revision_actor_temp',
279  [ 'revactor_page' => $this->dest->getArticleID() ],
280  [
281  'revactor_page' => $this->source->getArticleID(),
282  // Slightly hacky, but should work given the values assigned in this class
283  str_replace( 'rev_timestamp', 'revactor_timestamp', $this->timeWhere )
284  ],
285  __METHOD__
286  );
287 
288  // Make the source page a redirect if no revisions are left
289  $haveRevisions = $this->dbw->lockForUpdate(
290  'revision',
291  [ 'rev_page' => $this->source->getArticleID() ],
292  __METHOD__
293  );
294  if ( !$haveRevisions ) {
295  if ( $reason ) {
296  $reason = wfMessage(
297  'mergehistory-comment',
298  $this->source->getPrefixedText(),
299  $this->dest->getPrefixedText(),
300  $reason
301  )->inContentLanguage()->text();
302  } else {
303  $reason = wfMessage(
304  'mergehistory-autocomment',
305  $this->source->getPrefixedText(),
306  $this->dest->getPrefixedText()
307  )->inContentLanguage()->text();
308  }
309 
310  $contentHandler = ContentHandler::getForTitle( $this->source );
311  $redirectContent = $contentHandler->makeRedirectContent(
312  $this->dest,
313  wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
314  );
315 
316  if ( $redirectContent ) {
317  $redirectPage = WikiPage::factory( $this->source );
318  $redirectRevision = new Revision( [
319  'title' => $this->source,
320  'page' => $this->source->getArticleID(),
321  'comment' => $reason,
322  'content' => $redirectContent ] );
323  $redirectRevision->insertOn( $this->dbw );
324  $redirectPage->updateRevisionOn( $this->dbw, $redirectRevision );
325 
326  // Now, we record the link from the redirect to the new title.
327  // It should have no other outgoing links...
328  $this->dbw->delete(
329  'pagelinks',
330  [ 'pl_from' => $this->dest->getArticleID() ],
331  __METHOD__
332  );
333  $this->dbw->insert( 'pagelinks',
334  [
335  'pl_from' => $this->dest->getArticleID(),
336  'pl_from_namespace' => $this->dest->getNamespace(),
337  'pl_namespace' => $this->dest->getNamespace(),
338  'pl_title' => $this->dest->getDBkey() ],
339  __METHOD__
340  );
341  } else {
342  // Warning if we couldn't create the redirect
343  $status->warning( 'mergehistory-warning-redirect-not-created' );
344  }
345  } else {
346  $this->source->invalidateCache(); // update histories
347  }
348  $this->dest->invalidateCache(); // update histories
349 
350  // Duplicate watchers of the old article to the new article on history merge
351  $store = MediaWikiServices::getInstance()->getWatchedItemStore();
352  $store->duplicateAllAssociatedEntries( $this->source, $this->dest );
353 
354  // Update our logs
355  $logEntry = new ManualLogEntry( 'merge', 'merge' );
356  $logEntry->setPerformer( $user );
357  $logEntry->setComment( $reason );
358  $logEntry->setTarget( $this->source );
359  $logEntry->setParameters( [
360  '4::dest' => $this->dest->getPrefixedText(),
361  '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
362  ] );
363  $logId = $logEntry->insert();
364  $logEntry->publish( $logId );
365 
366  Hooks::run( 'ArticleMergeComplete', [ $this->source, $this->dest ] );
367 
368  $this->dbw->endAtomic( __METHOD__ );
369 
370  return $status;
371  }
372 }
static factory(Title $title)
Create a WikiPage object of the appropriate class for the given title.
Definition: WikiPage.php:142
Title $dest
Page to which history will be merged.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
Title $source
Page from which history will be merged.
const REVISION_LIMIT
Maximum number of revisions that can be merged at once.
int $revisionsMerged
Number of revisions merged (for Special:MergeHistory success message)
static numParam( $num)
Definition: Message.php:1038
const DB_MASTER
Definition: defines.php:26
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
wfMergeErrorArrays(... $args)
Merge arrays in the style of getUserPermissionsErrors, with duplicate removal e.g.
__construct(Title $source, Title $dest, $timestamp=false)
getMergedRevisionCount()
Get the number of revisions that were moved Used in the SpecialMergeHistory success message...
static getForTitle(Title $title)
Returns the appropriate ContentHandler singleton for the given title.
merge(User $user, $reason='')
Actually attempt the history move.
MWTimestamp $maxTimestamp
Maximum timestamp that we can use (oldest timestamp of dest)
insertOn( $dbw)
Insert a new revision into the database, returning the new revision ID number on success and dies hor...
Definition: Revision.php:948
MWTimestamp bool $timestampLimit
Timestamp upto which history from the source will be merged.
static matchSummarySpamRegex( $text)
Check given input text against $wgSummarySpamRegex, and return the text of the first match...
Definition: EditPage.php:2505
string $timeWhere
SQL WHERE condition that selects source revisions to insert into destination.
isValidMerge()
Does various sanity checks that the merge is valid.
getRevisionCount()
Get the number of revisions that will be moved.
Handles the backend logic of merging the histories of two pages.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
IDatabase $dbw
Database that we are using.
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
checkPermissions(User $user, $reason)
Check if the merge is possible.