6 * Created on Dec 29, 2015
8 * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License as published by
12 * the Free Software Foundation; either version 2 of the License, or
13 * (at your option) any later version.
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 * GNU General Public License for more details.
20 * You should have received a copy of the GNU General Public License along
21 * with this program; if not, write to the Free Software Foundation, Inc.,
22 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23 * http://www.gnu.org/copyleft/gpl.html
27 use Wikimedia\Timestamp\TimestampException
;
28 use Wikimedia\Rdbms\IDatabase
;
31 * Handles the backend logic of merging the histories of two
38 /** @const int Maximum number of revisions that can be merged at once */
39 const REVISION_LIMIT
= 5000;
41 /** @var Title Page from which history will be merged */
44 /** @var Title Page to which history will be merged */
47 /** @var IDatabase Database that we are using */
50 /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
51 protected $maxTimestamp;
53 /** @var string SQL WHERE condition that selects source revisions to insert into destination */
56 /** @var MWTimestamp|bool Timestamp upto which history from the source will be merged */
57 protected $timestampLimit;
59 /** @var int Number of revisions merged (for Special:MergeHistory success message) */
60 protected $revisionsMerged;
63 * @param Title $source Page from which history will be merged
64 * @param Title $dest Page to which history will be merged
65 * @param string|bool $timestamp Timestamp up to which history from the source will be merged
67 public function __construct( Title
$source, Title
$dest, $timestamp = false ) {
68 // Save the parameters
69 $this->source
= $source;
73 $this->dbw
= wfGetDB( DB_MASTER
);
75 // Max timestamp should be min of destination page
76 $firstDestTimestamp = $this->dbw
->selectField(
79 [ 'rev_page' => $this->dest
->getArticleID() ],
82 $this->maxTimestamp
= new MWTimestamp( $firstDestTimestamp );
84 // Get the timestamp pivot condition
87 // If we have a requested timestamp, use the
88 // latest revision up to that point as the insertion point
89 $mwTimestamp = new MWTimestamp( $timestamp );
90 $lastWorkingTimestamp = $this->dbw
->selectField(
95 $this->dbw
->addQuotes( $this->dbw
->timestamp( $mwTimestamp ) ),
96 'rev_page' => $this->source
->getArticleID()
100 $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
102 $timeInsert = $mwLastWorkingTimestamp;
103 $this->timestampLimit
= $mwLastWorkingTimestamp;
105 // If we don't, merge entire source page history into the
106 // beginning of destination page history
108 // Get the latest timestamp of the source
109 $lastSourceTimestamp = $this->dbw
->selectField(
110 [ 'page', 'revision' ],
112 [ 'page_id' => $this->source
->getArticleID(),
113 'page_latest = rev_id'
117 $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
119 $timeInsert = $this->maxTimestamp
;
120 $this->timestampLimit
= $lasttimestamp;
123 $this->timeWhere
= "rev_timestamp <= " .
124 $this->dbw
->addQuotes( $this->dbw
->timestamp( $timeInsert ) );
125 } catch ( TimestampException
$ex ) {
126 // The timestamp we got is screwed up and merge cannot continue
127 // This should be detected by $this->isValidMerge()
128 $this->timestampLimit
= false;
133 * Get the number of revisions that will be moved
136 public function getRevisionCount() {
137 $count = $this->dbw
->selectRowCount( 'revision', '1',
138 [ 'rev_page' => $this->source
->getArticleID(), $this->timeWhere
],
140 [ 'LIMIT' => self
::REVISION_LIMIT +
1 ]
147 * Get the number of revisions that were moved
148 * Used in the SpecialMergeHistory success message
151 public function getMergedRevisionCount() {
152 return $this->revisionsMerged
;
156 * Check if the merge is possible
158 * @param string $reason
161 public function checkPermissions( User
$user, $reason ) {
162 $status = new Status();
164 // Check if user can edit both pages
165 $errors = wfMergeErrorArrays(
166 $this->source
->getUserPermissionsErrors( 'edit', $user ),
167 $this->dest
->getUserPermissionsErrors( 'edit', $user )
170 // Convert into a Status object
172 foreach ( $errors as $error ) {
173 call_user_func_array( [ $status, 'fatal' ], $error );
178 if ( EditPage
::matchSummarySpamRegex( $reason ) !== false ) {
179 // This is kind of lame, won't display nice
180 $status->fatal( 'spamprotectiontext' );
183 // Check mergehistory permission
184 if ( !$user->isAllowed( 'mergehistory' ) ) {
185 // User doesn't have the right to merge histories
186 $status->fatal( 'mergehistory-fail-permission' );
193 * Does various sanity checks that the merge is
194 * valid. Only things based on the two pages
195 * should be checked here.
199 public function isValidMerge() {
200 $status = new Status();
202 // If either article ID is 0, then revisions cannot be reliably selected
203 if ( $this->source
->getArticleID() === 0 ) {
204 $status->fatal( 'mergehistory-fail-invalid-source' );
206 if ( $this->dest
->getArticleID() === 0 ) {
207 $status->fatal( 'mergehistory-fail-invalid-dest' );
210 // Make sure page aren't the same
211 if ( $this->source
->equals( $this->dest
) ) {
212 $status->fatal( 'mergehistory-fail-self-merge' );
215 // Make sure the timestamp is valid
216 if ( !$this->timestampLimit
) {
217 $status->fatal( 'mergehistory-fail-bad-timestamp' );
220 // $this->timestampLimit must be older than $this->maxTimestamp
221 if ( $this->timestampLimit
> $this->maxTimestamp
) {
222 $status->fatal( 'mergehistory-fail-timestamps-overlap' );
225 // Check that there are not too many revisions to move
226 if ( $this->timestampLimit
&& $this->getRevisionCount() > self
::REVISION_LIMIT
) {
227 $status->fatal( 'mergehistory-fail-toobig', Message
::numParam( self
::REVISION_LIMIT
) );
234 * Actually attempt the history move
236 * @todo if all versions of page A are moved to B and then a user
237 * tries to do a reverse-merge via the "unmerge" log link, then page
238 * A will still be a redirect (as it was after the original merge),
239 * though it will have the old revisions back from before (as expected).
240 * The user may have to "undo" the redirect manually to finish the "unmerge".
241 * Maybe this should delete redirects at the source page of merges?
244 * @param string $reason
245 * @return Status status of the history merge
247 public function merge( User
$user, $reason = '' ) {
248 $status = new Status();
250 // Check validity and permissions required for merge
251 $validCheck = $this->isValidMerge(); // Check this first to check for null pages
252 if ( !$validCheck->isOK() ) {
255 $permCheck = $this->checkPermissions( $user, $reason );
256 if ( !$permCheck->isOK() ) {
262 [ 'rev_page' => $this->dest
->getArticleID() ],
263 [ 'rev_page' => $this->source
->getArticleID(), $this->timeWhere
],
267 // Check if this did anything
268 $this->revisionsMerged
= $this->dbw
->affectedRows();
269 if ( $this->revisionsMerged
< 1 ) {
270 $status->fatal( 'mergehistory-fail-no-change' );
274 // Make the source page a redirect if no revisions are left
275 $haveRevisions = $this->dbw
->selectField(
278 [ 'rev_page' => $this->source
->getArticleID() ],
282 if ( !$haveRevisions ) {
285 'mergehistory-comment',
286 $this->source
->getPrefixedText(),
287 $this->dest
->getPrefixedText(),
289 )->inContentLanguage()->text();
292 'mergehistory-autocomment',
293 $this->source
->getPrefixedText(),
294 $this->dest
->getPrefixedText()
295 )->inContentLanguage()->text();
298 $contentHandler = ContentHandler
::getForTitle( $this->source
);
299 $redirectContent = $contentHandler->makeRedirectContent(
301 wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
304 if ( $redirectContent ) {
305 $redirectPage = WikiPage
::factory( $this->source
);
306 $redirectRevision = new Revision( [
307 'title' => $this->source
,
308 'page' => $this->source
->getArticleID(),
309 'comment' => $reason,
310 'content' => $redirectContent ] );
311 $redirectRevision->insertOn( $this->dbw
);
312 $redirectPage->updateRevisionOn( $this->dbw
, $redirectRevision );
314 // Now, we record the link from the redirect to the new title.
315 // It should have no other outgoing links...
318 [ 'pl_from' => $this->dest
->getArticleID() ],
321 $this->dbw
->insert( 'pagelinks',
323 'pl_from' => $this->dest
->getArticleID(),
324 'pl_from_namespace' => $this->dest
->getNamespace(),
325 'pl_namespace' => $this->dest
->getNamespace(),
326 'pl_title' => $this->dest
->getDBkey() ],
330 // Warning if we couldn't create the redirect
331 $status->warning( 'mergehistory-warning-redirect-not-created' );
334 $this->source
->invalidateCache(); // update histories
336 $this->dest
->invalidateCache(); // update histories
339 $logEntry = new ManualLogEntry( 'merge', 'merge' );
340 $logEntry->setPerformer( $user );
341 $logEntry->setComment( $reason );
342 $logEntry->setTarget( $this->source
);
343 $logEntry->setParameters( [
344 '4::dest' => $this->dest
->getPrefixedText(),
345 '5::mergepoint' => $this->timestampLimit
->getTimestamp( TS_MW
)
347 $logId = $logEntry->insert();
348 $logEntry->publish( $logId );
350 Hooks
::run( 'ArticleMergeComplete', [ $this->source
, $this->dest
] );