Implement action=mergehistory
authorGeoffrey Mon <geofbot@gmail.com>
Wed, 30 Dec 2015 04:53:34 +0000 (23:53 -0500)
committerGeoffrey Mon <geofbot@gmail.com>
Sat, 6 Feb 2016 15:39:27 +0000 (10:39 -0500)
* New class ApiMergeHistory handles action=mergehistory
* Merge History functionality moved from SpecialMergeHistory to
  MergeHistory
* SpecialMergeHistory now uses MergeHistory for actual merging
* Unit tests and i18n messages for above

Bug: T69742
Change-Id: Ic5078307dae78a2b3687e34a5d0a584988d483a1

autoload.php
includes/MergeHistory.php [new file with mode: 0644]
includes/api/ApiMain.php
includes/api/ApiMergeHistory.php [new file with mode: 0644]
includes/api/i18n/en.json
includes/api/i18n/qqq.json
includes/specials/SpecialMergeHistory.php
languages/i18n/en.json
languages/i18n/qqq.json
tests/phpunit/includes/MergeHistoryTest.php [new file with mode: 0644]

index 4d48de0..937b8c9 100644 (file)
@@ -52,6 +52,7 @@ $wgAutoloadLocalClasses = array(
        'ApiLogout' => __DIR__ . '/includes/api/ApiLogout.php',
        'ApiMain' => __DIR__ . '/includes/api/ApiMain.php',
        'ApiManageTags' => __DIR__ . '/includes/api/ApiManageTags.php',
+       'ApiMergeHistory' => __DIR__ . '/includes/api/ApiMergeHistory.php',
        'ApiMessage' => __DIR__ . '/includes/api/ApiMessage.php',
        'ApiModuleManager' => __DIR__ . '/includes/api/ApiModuleManager.php',
        'ApiMove' => __DIR__ . '/includes/api/ApiMove.php',
@@ -825,6 +826,7 @@ $wgAutoloadLocalClasses = array(
        'MemcachedPhpBagOStuff' => __DIR__ . '/includes/libs/objectcache/MemcachedPhpBagOStuff.php',
        'MemoizedCallable' => __DIR__ . '/includes/libs/MemoizedCallable.php',
        'MemoryFileBackend' => __DIR__ . '/includes/filebackend/MemoryFileBackend.php',
+       'MergeHistory' => __DIR__ . '/includes/MergeHistory.php',
        'MergeHistoryPager' => __DIR__ . '/includes/specials/SpecialMergeHistory.php',
        'MergeLogFormatter' => __DIR__ . '/includes/logging/MergeLogFormatter.php',
        'MergeMessageFileList' => __DIR__ . '/maintenance/mergeMessageFileList.php',
diff --git a/includes/MergeHistory.php b/includes/MergeHistory.php
new file mode 100644 (file)
index 0000000..a3861ee
--- /dev/null
@@ -0,0 +1,351 @@
+<?php
+
+/**
+ *
+ *
+ * Created on Dec 29, 2015
+ *
+ * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Handles the backend logic of merging the histories of two
+ * pages.
+ *
+ * @since 1.27
+ */
+class MergeHistory {
+
+       /** @const int Maximum number of revisions that can be merged at once (avoid too much slave lag) */
+       const REVISION_LIMIT = 5000;
+
+       /** @var Title Page from which history will be merged */
+       protected $source;
+
+       /** @var Title Page to which history will be merged */
+       protected $dest;
+
+       /** @var DatabaseBase Database that we are using */
+       protected $dbw;
+
+       /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
+       protected $maxTimestamp;
+
+       /** @var string SQL WHERE condition that selects source revisions to insert into destination */
+       protected $timeWhere;
+
+       /** @var MWTimestamp|boolean Timestamp upto which history from the source will be merged */
+       protected $timestampLimit;
+
+       /** @var integer Number of revisions merged (for Special:MergeHistory success message) */
+       protected $revisionsMerged;
+
+       /**
+        * MergeHistory constructor.
+        * @param Title $source Page from which history will be merged
+        * @param Title $dest Page to which history will be merged
+        * @param string|boolean $timestamp Timestamp up to which history from the source will be merged
+        */
+       public function __construct( Title $source, Title $dest, $timestamp = false ) {
+               // Save the parameters
+               $this->source = $source;
+               $this->dest = $dest;
+
+               // Get the database
+               $this->dbw = wfGetDB( DB_MASTER );
+
+               // Max timestamp should be min of destination page
+               $firstDestTimestamp = $this->dbw->selectField(
+                       'revision',
+                       'MIN(rev_timestamp)',
+                       array( 'rev_page' => $this->dest->getArticleID() ),
+                       __METHOD__
+               );
+               $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
+
+               // Get the timestamp pivot condition
+               try {
+                       if ( $timestamp ) {
+                               // If we have a requested timestamp, use the
+                               // latest revision up to that point as the insertion point
+                               $mwTimestamp = new MWTimestamp( $timestamp );
+                               $lastWorkingTimestamp = $this->dbw->selectField(
+                                       'revision',
+                                       'MAX(rev_timestamp)',
+                                       array(
+                                               'rev_timestamp <= ' . $this->dbw->timestamp( $mwTimestamp ),
+                                               'rev_page' => $this->source->getArticleID()
+                                       ),
+                                       __METHOD__
+                               );
+                               $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
+
+                               $timeInsert = $mwLastWorkingTimestamp;
+                               $this->timestampLimit = $mwLastWorkingTimestamp;
+                       } else {
+                               // If we don't, merge entire source page history into the
+                               // beginning of destination page history
+
+                               // Get the latest timestamp of the source
+                               $lastSourceTimestamp = $this->dbw->selectField(
+                                       array( 'page', 'revision' ),
+                                       'rev_timestamp',
+                                       array( 'page_id' => $this->source->getArticleID(),
+                                               'page_latest = rev_id'
+                                       ),
+                                       __METHOD__
+                               );
+                               $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
+
+                               $timeInsert = $this->maxTimestamp;
+                               $this->timestampLimit = $lasttimestamp;
+                       }
+
+                       $this->timeWhere = "rev_timestamp <= {$this->dbw->timestamp( $timeInsert )}";
+               } catch ( TimestampException $ex ) {
+                       // The timestamp we got is screwed up and merge cannot continue
+                       // This should be detected by $this->isValidMerge()
+                       $this->timestampLimit = false;
+               }
+       }
+
+       /**
+        * Get the number of revisions that will be moved
+        * @return int
+        */
+       public function getRevisionCount() {
+               $count = $this->dbw->selectRowCount( 'revision', '1',
+                       array( 'rev_page' => $this->source->getArticleID(), $this->timeWhere ),
+                       __METHOD__,
+                       array( 'LIMIT' => self::REVISION_LIMIT + 1 )
+               );
+
+               return $count;
+       }
+
+       /**
+        * Get the number of revisions that were moved
+        * Used in the SpecialMergeHistory success message
+        * @return int
+        */
+       public function getMergedRevisionCount() {
+               return $this->revisionsMerged;
+       }
+
+       /**
+        * Check if the merge is possible
+        * @param User $user
+        * @param string $reason
+        * @return Status
+        */
+       public function checkPermissions( User $user, $reason ) {
+               $status = new Status();
+
+               // Check if user can edit both pages
+               $errors = wfMergeErrorArrays(
+                       $this->source->getUserPermissionsErrors( 'edit', $user ),
+                       $this->dest->getUserPermissionsErrors( 'edit', $user )
+               );
+
+               // Convert into a Status object
+               if ( $errors ) {
+                       foreach ( $errors as $error ) {
+                               call_user_func_array( array( $status, 'fatal' ), $error );
+                       }
+               }
+
+               // Anti-spam
+               if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
+                       // This is kind of lame, won't display nice
+                       $status->fatal( 'spamprotectiontext' );
+               }
+
+               // Check mergehistory permission
+               if ( !$user->isAllowed( 'mergehistory' ) ) {
+                       // User doesn't have the right to merge histories
+                       $status->fatal( 'mergehistory-fail-permission' );
+               }
+
+               return $status;
+       }
+
+       /**
+        * Does various sanity checks that the merge is
+        * valid. Only things based on the two pages
+        * should be checked here.
+        *
+        * @return Status
+        */
+       public function isValidMerge() {
+               $status = new Status();
+
+               // If either article ID is 0, then revisions cannot be reliably selected
+               if ( $this->source->getArticleID() === 0 ) {
+                       $status->fatal( 'mergehistory-fail-invalid-source' );
+               }
+               if ( $this->dest->getArticleID() === 0 ) {
+                       $status->fatal( 'mergehistory-fail-invalid-dest' );
+               }
+
+               // Make sure page aren't the same
+               if ( $this->source->equals( $this->dest ) ) {
+                       $status->fatal( 'mergehistory-fail-self-merge' );
+               }
+
+               // Make sure the timestamp is valid
+               if ( !$this->timestampLimit ) {
+                       $status->fatal( 'mergehistory-fail-bad-timestamp' );
+               }
+
+               // $this->timestampLimit must be older than $this->maxTimestamp
+               if ( $this->timestampLimit > $this->maxTimestamp ) {
+                       $status->fatal( 'mergehistory-fail-timestamps-overlap' );
+               }
+
+               // Check that there are not too many revisions to move
+               if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
+                       $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
+               }
+
+               return $status;
+       }
+
+       /**
+        * Actually attempt the history move
+        *
+        * @todo if all versions of page A are moved to B and then a user
+        * tries to do a reverse-merge via the "unmerge" log link, then page
+        * A will still be a redirect (as it was after the original merge),
+        * though it will have the old revisions back from before (as expected).
+        * The user may have to "undo" the redirect manually to finish the "unmerge".
+        * Maybe this should delete redirects at the source page of merges?
+        *
+        * @param User $user
+        * @param string $reason
+        * @return Status status of the history merge
+        */
+       public function merge( User $user, $reason = '' ) {
+               $status = new Status();
+
+               // Check validity and permissions required for merge
+               $validCheck = $this->isValidMerge(); // Check this first to check for null pages
+               if ( !$validCheck->isOK() ) {
+                       return $validCheck;
+               }
+               $permCheck = $this->checkPermissions( $user, $reason );
+               if ( !$permCheck->isOK() ) {
+                       return $permCheck;
+               }
+
+               $this->dbw->update(
+                       'revision',
+                       array( 'rev_page' => $this->dest->getArticleID() ),
+                       array( 'rev_page' => $this->source->getArticleID(), $this->timeWhere ),
+                       __METHOD__
+               );
+
+               // Check if this did anything
+               $this->revisionsMerged = $this->dbw->affectedRows();
+               if ( $this->revisionsMerged < 1 ) {
+                       $status->fatal( 'mergehistory-fail-no-change' );
+                       return $status;
+               }
+
+               // Make the source page a redirect if no revisions are left
+               $haveRevisions = $this->dbw->selectField(
+                       'revision',
+                       'rev_timestamp',
+                       array( 'rev_page' => $this->source->getArticleID() ),
+                       __METHOD__,
+                       array( 'FOR UPDATE' )
+               );
+               if ( !$haveRevisions ) {
+                       if ( $reason ) {
+                               $reason = wfMessage(
+                                       'mergehistory-comment',
+                                       $this->source->getPrefixedText(),
+                                       $this->dest->getPrefixedText(),
+                                       $reason
+                               )->inContentLanguage()->text();
+                       } else {
+                               $reason = wfMessage(
+                                       'mergehistory-autocomment',
+                                       $this->source->getPrefixedText(),
+                                       $this->dest->getPrefixedText()
+                               )->inContentLanguage()->text();
+                       }
+
+                       $contentHandler = ContentHandler::getForTitle( $this->source );
+                       $redirectContent = $contentHandler->makeRedirectContent(
+                               $this->dest,
+                               wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
+                       );
+
+                       if ( $redirectContent ) {
+                               $redirectPage = WikiPage::factory( $this->source );
+                               $redirectRevision = new Revision( array(
+                                       'title' => $this->source,
+                                       'page' => $this->source->getArticleID(),
+                                       'comment' => $reason,
+                                       'content' => $redirectContent ) );
+                               $redirectRevision->insertOn( $this->dbw );
+                               $redirectPage->updateRevisionOn( $this->dbw, $redirectRevision );
+
+                               // Now, we record the link from the redirect to the new title.
+                               // It should have no other outgoing links...
+                               $this->dbw->delete(
+                                       'pagelinks',
+                                       array( 'pl_from' => $this->dest->getArticleID() ),
+                                       __METHOD__
+                               );
+                               $this->dbw->insert( 'pagelinks',
+                                       array(
+                                               'pl_from' => $this->dest->getArticleID(),
+                                               'pl_from_namespace' => $this->dest->getNamespace(),
+                                               'pl_namespace' => $this->dest->getNamespace(),
+                                               'pl_title' => $this->dest->getDBkey() ),
+                                       __METHOD__
+                               );
+                       } else {
+                               // Warning if we couldn't create the redirect
+                               $status->warning( 'mergehistory-warning-redirect-not-created' );
+                       }
+               } else {
+                       $this->source->invalidateCache(); // update histories
+               }
+               $this->dest->invalidateCache(); // update histories
+
+               // Update our logs
+               $logEntry = new ManualLogEntry( 'merge', 'merge' );
+               $logEntry->setPerformer( $user );
+               $logEntry->setComment( $reason );
+               $logEntry->setTarget( $this->source );
+               $logEntry->setParameters( array(
+                       '4::dest' => $this->dest->getPrefixedText(),
+                       '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
+               ) );
+               $logId = $logEntry->insert();
+               $logEntry->publish( $logId );
+
+               Hooks::run( 'ArticleMergeComplete', array( $this->source, $this->dest ) );
+
+               return $status;
+       }
+}
index 458fd18..f8192e5 100644 (file)
@@ -90,6 +90,7 @@ class ApiMain extends ApiBase {
                'revisiondelete' => 'ApiRevisionDelete',
                'managetags' => 'ApiManageTags',
                'tag' => 'ApiTag',
+               'mergehistory' => 'ApiMergeHistory',
        );
 
        /**
diff --git a/includes/api/ApiMergeHistory.php b/includes/api/ApiMergeHistory.php
new file mode 100644 (file)
index 0000000..8fa9d28
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+/**
+ *
+ *
+ * Created on Dec 29, 2015
+ *
+ * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * API Module to merge page histories
+ * @ingroup API
+ */
+class ApiMergeHistory extends ApiBase {
+
+       public function execute() {
+               $this->useTransactionalTimeLimit();
+
+               $user = $this->getUser();
+               $params = $this->extractRequestParams();
+
+               $this->requireOnlyOneParameter( $params, 'from', 'fromid' );
+               $this->requireOnlyOneParameter( $params, 'to', 'toid' );
+
+               // Get page objects (nonexistant pages get caught in MergeHistory::isValidMerge())
+               if ( isset( $params['from'] ) ) {
+                       $fromTitle = Title::newFromText( $params['from'] );
+                       if ( !$fromTitle || $fromTitle->isExternal() ) {
+                               $this->dieUsageMsg( array( 'invalidtitle', $params['from'] ) );
+                       }
+               } elseif ( isset( $params['fromid'] ) ) {
+                       $fromTitle = Title::newFromID( $params['fromid'] );
+                       if ( !$fromTitle ) {
+                               $this->dieUsageMsg( array( 'nosuchpageid', $params['fromid'] ) );
+                       }
+               }
+
+               if ( isset( $params['to'] ) ) {
+                       $toTitle = Title::newFromText( $params['to'] );
+                       if ( !$toTitle || $toTitle->isExternal() ) {
+                               $this->dieUsageMsg( array( 'invalidtitle', $params['to'] ) );
+                       }
+               } elseif ( isset( $params['toid'] ) ) {
+                       $toTitle = Title::newFromID( $params['toid'] );
+                       if ( !$toTitle ) {
+                               $this->dieUsageMsg( array( 'nosuchpageid', $params['toid'] ) );
+                       }
+               }
+
+               $reason = $params['reason'];
+               $timestamp = $params['timestamp'];
+
+               // Merge!
+               $status = $this->merge( $fromTitle, $toTitle, $timestamp, $reason );
+               if ( !$status->isOK() ) {
+                       $this->dieStatus( $status );
+               }
+
+               $r = array(
+                       'from' => $fromTitle->getPrefixedText(),
+                       'to' => $toTitle->getPrefixedText(),
+                       'timestamp' => wfTimestamp( TS_ISO_8601, $params['timestamp'] ),
+                       'reason' => $params['reason']
+               );
+               $result = $this->getResult();
+
+               $result->addValue( null, $this->getModuleName(), $r );
+       }
+
+       /**
+        * @param Title $from
+        * @param Title $to
+        * @param string $timestamp
+        * @param string $reason
+        * @return Status
+        */
+       protected function merge( Title $from, Title $to, $timestamp, $reason ) {
+               $mh = new MergeHistory( $from, $to, $timestamp );
+
+               return $mh->merge( $this->getUser(), $reason );
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'from' => null,
+                       'fromid' => array(
+                               ApiBase::PARAM_TYPE => 'integer'
+                       ),
+                       'to' => null,
+                       'toid' => array(
+                               ApiBase::PARAM_TYPE => 'integer'
+                       ),
+                       'timestamp' => array(
+                               ApiBase::PARAM_TYPE => 'timestamp'
+                       ),
+                       'reason' => '',
+               );
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       protected function getExamplesMessages() {
+               return array(
+                       'action=mergehistory&from=Oldpage&to=Newpage&token=123ABC&' .
+                       'reason=Reason'
+                       => 'apihelp-mergehistory-example-merge',
+                       'action=mergehistory&from=Oldpage&to=Newpage&token=123ABC&' .
+                       'reason=Reason&timestamp=2015-12-31T04%3A37%3A41Z' // TODO
+                       => 'apihelp-mergehistory-example-merge-timestamp',
+               );
+       }
+
+       public function getHelpUrls() {
+               return 'https://www.mediawiki.org/wiki/API:Mergehistory';
+       }
+}
index a1b303f..4a1f2f1 100644 (file)
        "apihelp-managetags-example-activate": "Activate a tag named <kbd>spam</kbd> with the reason <kbd>For use in edit patrolling</kbd>",
        "apihelp-managetags-example-deactivate": "Deactivate a tag named <kbd>spam</kbd> with the reason <kbd>No longer required</kbd>",
 
+       "apihelp-mergehistory-description": "Merge page histories.",
+       "apihelp-mergehistory-param-from": "Title of the page from which history will be merged. Cannot be used together with <var>$1fromid</var>.",
+       "apihelp-mergehistory-param-fromid": "Page ID of the page from which history will be merged. Cannot be used together with <var>$1from</var>.",
+       "apihelp-mergehistory-param-to": "Title of the page to which history will be merged. Cannot be used together with <var>$1toid</var>.",
+       "apihelp-mergehistory-param-toid": "Page ID of the page to which history will be merged. Cannot be used together with <var>$1to</var>.",
+       "apihelp-mergehistory-param-timestamp": "Timestamp up to which revisions will be moved from the source page's history to the destination page's history. If omitted, the entire page history of the source page will be merged into the destination page.",
+       "apihelp-mergehistory-param-reason": "Reason for the history merge.",
+       "apihelp-mergehistory-example-merge": "Merge the entire history of <kbd>Oldpage</kbd> into <kbd>Newpage</kbd>.",
+       "apihelp-mergehistory-example-merge-timestamp": "Merge the page revisions of <kbd>Oldpage</kbd> dating up to <kbd>2015-12-31T04:37:41Z</kbd> into <kbd>Newpage</kbd>.",
+
        "apihelp-move-description": "Move a page.",
        "apihelp-move-param-from": "Title of the page to rename. Cannot be used together with <var>$1fromid</var>.",
        "apihelp-move-param-fromid": "Page ID of the page to rename. Cannot be used together with <var>$1from</var>.",
index e3354aa..df4f881 100644 (file)
        "apihelp-managetags-example-delete": "{{doc-apihelp-example|managetags|info={{doc-important|The text \"vandlaism\" in this message is intentionally misspelled; the example being documented by this message is the deletion of a misspelled tag.}}}}",
        "apihelp-managetags-example-activate": "{{doc-apihelp-example|managetags}}",
        "apihelp-managetags-example-deactivate": "{{doc-apihelp-example|managetags}}",
+       "apihelp-mergehistory-description": "{{doc-apihelp-description|mergehistory}}",
+       "apihelp-mergehistory-param-from": "{{doc-apihelp-param|mergehistory|from}}",
+       "apihelp-mergehistory-param-fromid": "{{doc-apihelp-param|mergehistory|fromid}}",
+       "apihelp-mergehistory-param-to": "{{doc-apihelp-param|mergehistory|to}}",
+       "apihelp-mergehistory-param-toid": "{{doc-apihelp-param|mergehistory|toid}}",
+       "apihelp-mergehistory-param-timestamp": "{{doc-apihelp-param|mergehistory|timestamp}}",
+       "apihelp-mergehistory-param-reason": "{{doc-apihelp-param|mergehistory|reason}}",
+       "apihelp-mergehistory-example-merge": "{{doc-apihelp-example|mergehistory}}",
+       "apihelp-mergehistory-example-merge-timestamp": "{{doc-apihelp-example|mergehistory}}",
        "apihelp-move-description": "{{doc-apihelp-description|move}}",
        "apihelp-move-param-from": "{{doc-apihelp-param|move|from}}",
        "apihelp-move-param-fromid": "{{doc-apihelp-param|move|fromid}}",
index 0a25180..0cefb38 100644 (file)
@@ -347,138 +347,17 @@ class SpecialMergeHistory extends SpecialPage {
                if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) {
                        return false;
                }
-               # Verify that this timestamp is valid
-               # Must be older than the destination page
-               $dbw = wfGetDB( DB_MASTER );
-               # Get timestamp into DB format
-               $this->mTimestamp = $this->mTimestamp ? $dbw->timestamp( $this->mTimestamp ) : '';
-               # Max timestamp should be min of destination page
-               $maxtimestamp = $dbw->selectField(
-                       'revision',
-                       'MIN(rev_timestamp)',
-                       array( 'rev_page' => $this->mDestID ),
-                       __METHOD__
-               );
-               # Destination page must exist with revisions
-               if ( !$maxtimestamp ) {
-                       $this->getOutput()->addWikiMsg( 'mergehistory-fail' );
 
-                       return false;
-               }
-               # Get the latest timestamp of the source
-               $lasttimestamp = $dbw->selectField(
-                       array( 'page', 'revision' ),
-                       'rev_timestamp',
-                       array( 'page_id' => $this->mTargetID, 'page_latest = rev_id' ),
-                       __METHOD__
-               );
-               # $this->mTimestamp must be older than $maxtimestamp
-               if ( $this->mTimestamp >= $maxtimestamp ) {
-                       $this->getOutput()->addWikiMsg( 'mergehistory-fail' );
+               // MergeHistory object
+               $mh = new MergeHistory( $targetTitle, $destTitle, $this->mTimestamp );
 
+               // Merge!
+               $mergeStatus = $mh->merge( $this->getUser(), $this->mComment );
+               if ( !$mergeStatus->isOK() ) {
+                       // Failed merge
+                       $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() );
                        return false;
                }
-               # Get the timestamp pivot condition
-               if ( $this->mTimestamp ) {
-                       $timewhere = "rev_timestamp <= {$this->mTimestamp}";
-                       $timestampLimit = wfTimestamp( TS_MW, $this->mTimestamp );
-               } else {
-                       $timewhere = "rev_timestamp <= {$maxtimestamp}";
-                       $timestampLimit = wfTimestamp( TS_MW, $lasttimestamp );
-               }
-               # Check that there are not too many revisions to move
-               $limit = 5000; // avoid too much slave lag
-               $count = $dbw->selectRowCount( 'revision', '1',
-                       array( 'rev_page' => $this->mTargetID, $timewhere ),
-                       __METHOD__,
-                       array( 'LIMIT' => $limit + 1 )
-               );
-               if ( $count > $limit ) {
-                       $this->getOutput()->addWikiMsg( 'mergehistory-fail-toobig' );
-
-                       return false;
-               }
-               # Do the moving...
-               $dbw->update(
-                       'revision',
-                       array( 'rev_page' => $this->mDestID ),
-                       array( 'rev_page' => $this->mTargetID, $timewhere ),
-                       __METHOD__
-               );
-
-               $count = $dbw->affectedRows();
-               # Make the source page a redirect if no revisions are left
-               $haveRevisions = $dbw->selectField(
-                       'revision',
-                       'rev_timestamp',
-                       array( 'rev_page' => $this->mTargetID ),
-                       __METHOD__,
-                       array( 'FOR UPDATE' )
-               );
-               if ( !$haveRevisions ) {
-                       if ( $this->mComment ) {
-                               $comment = $this->msg(
-                                       'mergehistory-comment',
-                                       $targetTitle->getPrefixedText(),
-                                       $destTitle->getPrefixedText(),
-                                       $this->mComment
-                               )->inContentLanguage()->text();
-                       } else {
-                               $comment = $this->msg(
-                                       'mergehistory-autocomment',
-                                       $targetTitle->getPrefixedText(),
-                                       $destTitle->getPrefixedText()
-                               )->inContentLanguage()->text();
-                       }
-
-                       $contentHandler = ContentHandler::getForTitle( $targetTitle );
-                       $redirectContent = $contentHandler->makeRedirectContent( $destTitle );
-
-                       if ( $redirectContent ) {
-                               $redirectPage = WikiPage::factory( $targetTitle );
-                               $redirectRevision = new Revision( array(
-                                       'title' => $targetTitle,
-                                       'page' => $this->mTargetID,
-                                       'comment' => $comment,
-                                       'content' => $redirectContent ) );
-                               $redirectRevision->insertOn( $dbw );
-                               $redirectPage->updateRevisionOn( $dbw, $redirectRevision );
-
-                               # Now, we record the link from the redirect to the new title.
-                               # It should have no other outgoing links...
-                               $dbw->delete( 'pagelinks', array( 'pl_from' => $this->mDestID ), __METHOD__ );
-                               $dbw->insert( 'pagelinks',
-                                       array(
-                                               'pl_from' => $this->mDestID,
-                                               'pl_from_namespace' => $destTitle->getNamespace(),
-                                               'pl_namespace' => $destTitle->getNamespace(),
-                                               'pl_title' => $destTitle->getDBkey() ),
-                                       __METHOD__
-                               );
-                       } else {
-                               // would be nice to show a warning if we couldn't create a redirect
-                       }
-               } else {
-                       $targetTitle->invalidateCache(); // update histories
-               }
-               $destTitle->invalidateCache(); // update histories
-               # Check if this did anything
-               if ( !$count ) {
-                       $this->getOutput()->addWikiMsg( 'mergehistory-fail' );
-
-                       return false;
-               }
-               # Update our logs
-               $logEntry = new ManualLogEntry( 'merge', 'merge' );
-               $logEntry->setPerformer( $this->getUser() );
-               $logEntry->setComment( $this->mComment );
-               $logEntry->setTarget( $targetTitle );
-               $logEntry->setParameters( array(
-                       '4::dest' => $destTitle->getPrefixedText(),
-                       '5::mergepoint' => $timestampLimit
-               ) );
-               $logId = $logEntry->insert();
-               $logEntry->publish( $logId );
 
                $targetLink = Linker::link(
                        $targetTitle,
@@ -490,11 +369,9 @@ class SpecialMergeHistory extends SpecialPage {
                $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' )
                        ->rawParams( $targetLink )
                        ->params( $destTitle->getPrefixedText() )
-                       ->numParams( $count )
+                       ->numParams( $mh->getMergedRevisionCount() )
                );
 
-               Hooks::run( 'ArticleMergeComplete', array( $targetTitle, $destTitle ) );
-
                return true;
        }
 
index 2e1df41..a68da7b 100644 (file)
        "mergehistory-empty": "No revisions can be merged.",
        "mergehistory-done": "$3 {{PLURAL:$3|revision|revisions}} of $1 {{PLURAL:$3|was|were}} merged into [[:$2]].",
        "mergehistory-fail": "Unable to perform history merge, please recheck the page and time parameters.",
-       "mergehistory-fail-toobig" : "Unable to perform history merge as more than the limit of $1 {{PLURAL:$1|revision|revisions}} would be moved.",
+       "mergehistory-fail-bad-timestamp": "Timestamp is invalid.",
+       "mergehistory-fail-invalid-source": "Source page is invalid.",
+       "mergehistory-fail-invalid-dest": "Destination page is invalid.",
+       "mergehistory-fail-no-change": "History merge did not merge any revisions. Please recheck the page and time parameters.",
+       "mergehistory-fail-permission": "Insufficient permissions to merge history.",
+       "mergehistory-fail-self-merge": "Source and destination pages are the same.",
+       "mergehistory-fail-timestamps-overlap": "Source revisions overlap or come after destination revisions.",
+       "mergehistory-fail-toobig": "Unable to perform history merge as more than the limit of $1 {{PLURAL:$1|revision|revisions}} would be moved.",
+       "mergehistory-warning-redirect-not-created": "",
        "mergehistory-no-source": "Source page $1 does not exist.",
        "mergehistory-no-destination": "Destination page $1 does not exist.",
        "mergehistory-invalid-source": "Source page must be a valid title.",
        "mergehistory-same-destination": "Source and destination pages cannot be the same",
        "mergehistory-reason": "Reason:",
        "mergehistory-revisionrow": "$1 ($2) $3 . . $4 $5 $6",
+       "mergehistory-redirect-text": "",
        "mergelog": "Merge log",
        "pagemerge-logentry": "merged [[$1]] into [[$2]] (revisions up to $3)",
        "revertmerge": "Unmerge",
index fec3079..0bc7a9e 100644 (file)
        "mergehistory-empty": "Used in [[Special:MergeHistory]].",
        "mergehistory-done": "Success message shown on [[Special:MergeHistory]].\n* $1 - link to target page\n* $2 - destination page title\n* $3 - number of revisions which succeeded to merge",
        "mergehistory-fail": "Used as error message in [[Special:MergeHistory]].",
-       "mergehistory-fail-toobig": "Used as error message in [[Special:MergeHistory]].\n* $1 - maximum allowed number of revisions that can be moved",
+       "mergehistory-fail-bad-timestamp": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-invalid-source": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-invalid-dest": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-no-change": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-permission": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-self-merge": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-timestamps-overlap": "Used as error message in [[Special:MergeHistory]] API.",
+       "mergehistory-fail-toobig": "Used as error message in [[Special:MergeHistory]] and API.\n* $1 - maximum allowed number of revisions that can be moved",
+       "mergehistory-warning-redirect-not-created": "Used as warning message in [[Special:MergeHistory]] API.",
        "mergehistory-no-source": "Used as error message in [[Special:MergeHistory]].\n* $1 - source page title\nSee also:\n* {{msg-mw|mergehistory-invalid-source}}\n* {{msg-mw|mergehistory-invalid-destination}}\n* {{msg-mw|mergehistory-no-destination}}\n* {{msg-mw|mergehistory-same-destination}}",
        "mergehistory-no-destination": "Used as error message in [[Special:MergeHistory]].\n* $1 - destination page title\nSee also:\n* {{msg-mw|mergehistory-invalid-source}}\n* {{msg-mw|mergehistory-no-source}}\n* {{msg-mw|mergehistory-invalid-destination}}\n* {{msg-mw|mergehistory-same-destination}}",
        "mergehistory-invalid-source": "Used as error message in [[Special:MergeHistory]].\n\nSee also:\n* {{msg-mw|mergehistory-no-source}}\n* {{msg-mw|mergehistory-invalid-destination}}\n* {{msg-mw|mergehistory-no-destination}}\n* {{msg-mw|mergehistory-same-destination}}",
        "mergehistory-same-destination": "Error message shown on [[Special:MergeHistory]] when the user entered the same page title to both source and destination\n\nSee also:\n* {{msg-mw|mergehistory-invalid-source}}\n* {{msg-mw|mergehistory-no-source}}\n* {{msg-mw|mergehistory-invalid-destination}}\n* {{msg-mw|mergehistory-no-destination}}",
        "mergehistory-reason": "{{Identical|Reason}}",
        "mergehistory-revisionrow": "{{Optional}}\nA revision row in the merge history page. Parameters:\n* $1 - a radio button to indicate a merge point\n* $2 - a link to the last revision of a page ({{msg-mw|Last}})\n* $3 - a page link\n* $4 - a user link\n* $5 - a revision size\n* $6 - a revision comment",
+       "mergehistory-redirect-text": "{{ignored}}The text that's added to a redirected page when that redirect is created as part of a history merge.",
        "mergelog": "{{doc-logpage}}\n\nThis is the name of a log of merge actions done on [[Special:MergeHistory]]. This special page and this log is not enabled by default.",
        "pagemerge-logentry": "{{ignored}}This is a ''logentry'' message only used on IRC.\n\nParameters:\n* $1 - the page name of the source of the content to be merged\n* $2 - the page into which the content is merged\n* $3 - a timestamp of limit\n\nThe log and its associated special page 'MergeHistory' is not enabled by default.\n\nPlease note that the parameters in a log entry will appear in the log only in the default language of the wiki. View [[Special:Log]] for examples on translatewiki.net with English default language.",
        "revertmerge": "Used as link text",
diff --git a/tests/phpunit/includes/MergeHistoryTest.php b/tests/phpunit/includes/MergeHistoryTest.php
new file mode 100644 (file)
index 0000000..0c1a7a8
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * @group Database
+ */
+class MergeHistoryTest extends MediaWikiTestCase {
+
+       /**
+        * Make some pages to work with
+        */
+       public function addDBData() {
+               // Pages that won't actually be merged
+               $this->insertPage( 'Test' );
+               $this->insertPage( 'Test2' );
+
+               // Pages that will be merged
+               $this->insertPage( 'Merge1' );
+               $this->insertPage( 'Merge2' );
+       }
+
+       /**
+        * @dataProvider provideIsValidMerge
+        * @covers MergeHistory::isValidMerge
+        * @param $source string Source page
+        * @param $dest string Destination page
+        * @param $timestamp string|bool Timestamp up to which revisions are merged (or false for all)
+        * @param $error string|bool Expected error for test (or true for no error)
+        */
+       public function testIsValidMerge( $source, $dest, $timestamp, $error ) {
+               $this->setMwGlobals( 'wgContentHandlerUseDB', false );
+               $mh = new MergeHistory(
+                       Title::newFromText( $source ),
+                       Title::newFromText( $dest ),
+                       $timestamp
+               );
+               $status = $mh->isValidMerge();
+               if ( $error === true ) {
+                       $this->assertTrue( $status->isGood() );
+               } else {
+                       $this->assertTrue( $status->hasMessage( $error ) );
+               }
+       }
+
+       public static function provideIsValidMerge() {
+               return array(
+                       // for MergeHistory::isValidMerge
+                       array( 'Test', 'Test2', false, true ),
+                       // Although this timestamp is after the latest timestamp of both pages,
+                       // MergeHistory should select the latest source timestamp up to this which should
+                       // still work for the merge.
+                       array( 'Test', 'Test2', strtotime( 'tomorrow' ), true ),
+                       array( 'Test', 'Test', false, 'mergehistory-fail-self-merge' ),
+                       array( 'Nonexistant', 'Test2', false, 'mergehistory-fail-invalid-source' ),
+                       array( 'Test', 'Nonexistant', false, 'mergehistory-fail-invalid-dest' ),
+                       array(
+                               'Test',
+                               'Test2',
+                               'This is obviously an invalid timestamp',
+                               'mergehistory-fail-bad-timestamp'
+                       ),
+               );
+       }
+
+       /**
+        * Test merge revision limit checking
+        * @covers MergeHistory::isValidMerge
+        */
+       public function testIsValidMergeRevisionLimit() {
+               $limit = MergeHistory::REVISION_LIMIT;
+
+               $mh = $this->getMockBuilder( 'MergeHistory' )
+                       ->setMethods( array( 'getRevisionCount' ) )
+                       ->setConstructorArgs( array(
+                               Title::newFromText( 'Test' ),
+                               Title::newFromText( 'Test2' ),
+                       ) )
+                       ->getMock();
+               $mh->expects( $this->once() )
+                       ->method( 'getRevisionCount' )
+                       ->will( $this->returnValue( $limit + 1 ) );
+
+               $status = $mh->isValidMerge();
+               $this->assertTrue( $status->hasMessage( 'mergehistory-fail-toobig' ) );
+               $errors = $status->getErrorsByType( 'error' );
+               $params = $errors[0]['params'];
+               $this->assertEquals( $params[0], Message::numParam( $limit ) );
+       }
+
+       /**
+        * Test user permission checking
+        * @covers MergeHistory::checkPermissions
+        */
+       public function testCheckPermissions() {
+               $mh = new MergeHistory(
+                       Title::newFromText( 'Test' ),
+                       Title::newFromText( 'Test2' )
+               );
+
+               // Sysop with mergehistory permission
+               $sysop = User::newFromName( 'UTSysop' );
+               $status = $mh->checkPermissions( $sysop, '' );
+               $this->assertTrue( $status->isOK() );
+
+               // Normal user
+               $notSysop = User::newFromName( 'UTNotSysop' );
+               $notSysop->addToDatabase();
+               $status = $mh->checkPermissions( $notSysop, '' );
+               $this->assertTrue( $status->hasMessage( 'mergehistory-fail-permission' ) );
+       }
+
+       /**
+        * Test merged revision count
+        * @covers MergeHistory::getMergedRevisionCount
+        */
+       public function testGetMergedRevisionCount() {
+               $mh = new MergeHistory(
+                       Title::newFromText( 'Merge1' ),
+                       Title::newFromText( 'Merge2' )
+               );
+
+               $mh->merge( User::newFromName( 'UTSysop' ) );
+               $this->assertEquals( $mh->getMergedRevisionCount(), 1 );
+       }
+}