4 * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
23 use MediaWiki\MediaWikiServices
;
24 use Wikimedia\Timestamp\TimestampException
;
25 use Wikimedia\Rdbms\IDatabase
;
28 * Handles the backend logic of merging the histories of two
35 /** @const int Maximum number of revisions that can be merged at once */
36 const REVISION_LIMIT
= 5000;
38 /** @var Title Page from which history will be merged */
41 /** @var Title Page to which history will be merged */
44 /** @var IDatabase Database that we are using */
47 /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
48 protected $maxTimestamp;
50 /** @var string SQL WHERE condition that selects source revisions to insert into destination */
53 /** @var MWTimestamp|bool Timestamp upto which history from the source will be merged */
54 protected $timestampLimit;
56 /** @var int Number of revisions merged (for Special:MergeHistory success message) */
57 protected $revisionsMerged;
60 * @param Title $source Page from which history will be merged
61 * @param Title $dest Page to which history will be merged
62 * @param string|bool $timestamp Timestamp up to which history from the source will be merged
64 public function __construct( Title
$source, Title
$dest, $timestamp = false ) {
65 // Save the parameters
66 $this->source
= $source;
70 $this->dbw
= wfGetDB( DB_MASTER
);
72 // Max timestamp should be min of destination page
73 $firstDestTimestamp = $this->dbw
->selectField(
76 [ 'rev_page' => $this->dest
->getArticleID() ],
79 $this->maxTimestamp
= new MWTimestamp( $firstDestTimestamp );
81 // Get the timestamp pivot condition
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(
92 $this->dbw
->addQuotes( $this->dbw
->timestamp( $mwTimestamp ) ),
93 'rev_page' => $this->source
->getArticleID()
97 $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
99 $timeInsert = $mwLastWorkingTimestamp;
100 $this->timestampLimit
= $mwLastWorkingTimestamp;
102 // If we don't, merge entire source page history into the
103 // beginning of destination page history
105 // Get the latest timestamp of the source
106 $lastSourceTimestamp = $this->dbw
->selectField(
107 [ 'page', 'revision' ],
109 [ 'page_id' => $this->source
->getArticleID(),
110 'page_latest = rev_id'
114 $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
116 $timeInsert = $this->maxTimestamp
;
117 $this->timestampLimit
= $lasttimestamp;
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;
130 * Get the number of revisions that will be moved
133 public function getRevisionCount() {
134 $count = $this->dbw
->selectRowCount( 'revision', '1',
135 [ 'rev_page' => $this->source
->getArticleID(), $this->timeWhere
],
137 [ 'LIMIT' => self
::REVISION_LIMIT +
1 ]
144 * Get the number of revisions that were moved
145 * Used in the SpecialMergeHistory success message
148 public function getMergedRevisionCount() {
149 return $this->revisionsMerged
;
153 * Check if the merge is possible
155 * @param string $reason
158 public function checkPermissions( User
$user, $reason ) {
159 $status = new Status();
161 // Check if user can edit both pages
162 $errors = wfMergeErrorArrays(
163 $this->source
->getUserPermissionsErrors( 'edit', $user ),
164 $this->dest
->getUserPermissionsErrors( 'edit', $user )
167 // Convert into a Status object
169 foreach ( $errors as $error ) {
170 $status->fatal( ...$error );
175 if ( EditPage
::matchSummarySpamRegex( $reason ) !== false ) {
176 // This is kind of lame, won't display nice
177 $status->fatal( 'spamprotectiontext' );
180 // Check mergehistory permission
181 if ( !$user->isAllowed( 'mergehistory' ) ) {
182 // User doesn't have the right to merge histories
183 $status->fatal( 'mergehistory-fail-permission' );
190 * Does various sanity checks that the merge is
191 * valid. Only things based on the two pages
192 * should be checked here.
196 public function isValidMerge() {
197 $status = new Status();
199 // If either article ID is 0, then revisions cannot be reliably selected
200 if ( $this->source
->getArticleID() === 0 ) {
201 $status->fatal( 'mergehistory-fail-invalid-source' );
203 if ( $this->dest
->getArticleID() === 0 ) {
204 $status->fatal( 'mergehistory-fail-invalid-dest' );
207 // Make sure page aren't the same
208 if ( $this->source
->equals( $this->dest
) ) {
209 $status->fatal( 'mergehistory-fail-self-merge' );
212 // Make sure the timestamp is valid
213 if ( !$this->timestampLimit
) {
214 $status->fatal( 'mergehistory-fail-bad-timestamp' );
217 // $this->timestampLimit must be older than $this->maxTimestamp
218 if ( $this->timestampLimit
> $this->maxTimestamp
) {
219 $status->fatal( 'mergehistory-fail-timestamps-overlap' );
222 // Check that there are not too many revisions to move
223 if ( $this->timestampLimit
&& $this->getRevisionCount() > self
::REVISION_LIMIT
) {
224 $status->fatal( 'mergehistory-fail-toobig', Message
::numParam( self
::REVISION_LIMIT
) );
231 * Actually attempt the history move
233 * @todo if all versions of page A are moved to B and then a user
234 * tries to do a reverse-merge via the "unmerge" log link, then page
235 * A will still be a redirect (as it was after the original merge),
236 * though it will have the old revisions back from before (as expected).
237 * The user may have to "undo" the redirect manually to finish the "unmerge".
238 * Maybe this should delete redirects at the source page of merges?
241 * @param string $reason
242 * @return Status status of the history merge
244 public function merge( User
$user, $reason = '' ) {
245 $status = new Status();
247 // Check validity and permissions required for merge
248 $validCheck = $this->isValidMerge(); // Check this first to check for null pages
249 if ( !$validCheck->isOK() ) {
252 $permCheck = $this->checkPermissions( $user, $reason );
253 if ( !$permCheck->isOK() ) {
259 [ 'rev_page' => $this->dest
->getArticleID() ],
260 [ 'rev_page' => $this->source
->getArticleID(), $this->timeWhere
],
264 // Check if this did anything
265 $this->revisionsMerged
= $this->dbw
->affectedRows();
266 if ( $this->revisionsMerged
< 1 ) {
267 $status->fatal( 'mergehistory-fail-no-change' );
271 // Make the source page a redirect if no revisions are left
272 $haveRevisions = $this->dbw
->selectField(
275 [ 'rev_page' => $this->source
->getArticleID() ],
279 if ( !$haveRevisions ) {
282 'mergehistory-comment',
283 $this->source
->getPrefixedText(),
284 $this->dest
->getPrefixedText(),
286 )->inContentLanguage()->text();
289 'mergehistory-autocomment',
290 $this->source
->getPrefixedText(),
291 $this->dest
->getPrefixedText()
292 )->inContentLanguage()->text();
295 $contentHandler = ContentHandler
::getForTitle( $this->source
);
296 $redirectContent = $contentHandler->makeRedirectContent(
298 wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
301 if ( $redirectContent ) {
302 $redirectPage = WikiPage
::factory( $this->source
);
303 $redirectRevision = new Revision( [
304 'title' => $this->source
,
305 'page' => $this->source
->getArticleID(),
306 'comment' => $reason,
307 'content' => $redirectContent ] );
308 $redirectRevision->insertOn( $this->dbw
);
309 $redirectPage->updateRevisionOn( $this->dbw
, $redirectRevision );
311 // Now, we record the link from the redirect to the new title.
312 // It should have no other outgoing links...
315 [ 'pl_from' => $this->dest
->getArticleID() ],
318 $this->dbw
->insert( 'pagelinks',
320 'pl_from' => $this->dest
->getArticleID(),
321 'pl_from_namespace' => $this->dest
->getNamespace(),
322 'pl_namespace' => $this->dest
->getNamespace(),
323 'pl_title' => $this->dest
->getDBkey() ],
327 // Warning if we couldn't create the redirect
328 $status->warning( 'mergehistory-warning-redirect-not-created' );
331 $this->source
->invalidateCache(); // update histories
333 $this->dest
->invalidateCache(); // update histories
335 // Duplicate watchers of the old article to the new article on history merge
336 $store = MediaWikiServices
::getInstance()->getWatchedItemStore();
337 $store->duplicateAllAssociatedEntries( $this->source
, $this->dest
);
340 $logEntry = new ManualLogEntry( 'merge', 'merge' );
341 $logEntry->setPerformer( $user );
342 $logEntry->setComment( $reason );
343 $logEntry->setTarget( $this->source
);
344 $logEntry->setParameters( [
345 '4::dest' => $this->dest
->getPrefixedText(),
346 '5::mergepoint' => $this->timestampLimit
->getTimestamp( TS_MW
)
348 $logId = $logEntry->insert();
349 $logEntry->publish( $logId );
351 Hooks
::run( 'ArticleMergeComplete', [ $this->source
, $this->dest
] );