From 9b070bf461ddf1c9c00edbc09709ee311ac13a1d Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Sun, 25 Nov 2007 09:23:26 +0000 Subject: [PATCH] *Add a special page to merge non-overlapping page histories to fix copy-paste moves ect. This requires the 'mergehistory' right to test, which is for sysops, but commented out for now. *Add merge log *Do not list revisiondelete as a special page with no context --- includes/DefaultSettings.php | 5 + includes/LogPage.php | 5 + includes/SpecialLog.php | 7 +- includes/SpecialMergeHistory.php | 388 ++++++++++++++++++++++++++++++ includes/SpecialPage.php | 5 +- languages/messages/MessagesEn.php | 24 ++ 6 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 includes/SpecialMergeHistory.php diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 974e9695e2..b54767b174 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1099,6 +1099,7 @@ $wgGroupPermissions['sysop']['ipblock-exempt'] = true; $wgGroupPermissions['sysop']['blockemail'] = true; $wgGroupPermissions['sysop']['markbotedits'] = true; $wgGroupPermissions['sysop']['suppressredirect'] = true; +#$wgGroupPermissions['sysop']['mergehistory'] = true; // Permission to change users' group assignments $wgGroupPermissions['bureaucrat']['userrights'] = true; @@ -2276,6 +2277,7 @@ $wgLogTypes = array( '', 'move', 'import', 'patrol', + 'merge', ); /** @@ -2294,6 +2296,7 @@ $wgLogNames = array( 'move' => 'movelogpage', 'import' => 'importlogpage', 'patrol' => 'patrol-log-page', + 'merge' => 'mergelog', ); /** @@ -2312,6 +2315,7 @@ $wgLogHeaders = array( 'move' => 'movelogpagetext', 'import' => 'importlogpagetext', 'patrol' => 'patrol-log-header', + 'merge' => 'mergelogpagetext', ); /** @@ -2337,6 +2341,7 @@ $wgLogActions = array( 'move/move_redir' => '1movedto2_redir', 'import/upload' => 'import-logentry-upload', 'import/interwiki' => 'import-logentry-interwiki', + 'merge/merge' => 'pagemerge-logentry', ); /** diff --git a/includes/LogPage.php b/includes/LogPage.php index e58f65fdd9..7c89df7682 100644 --- a/includes/LogPage.php +++ b/includes/LogPage.php @@ -173,6 +173,11 @@ class LogPage { $text = $wgContLang->ucfirst( $title->getText() ); $titleLink = $skin->makeLinkObj( Title::makeTitle( NS_USER, $text ) ); break; + case 'merge': + $titleLink = $skin->makeLinkObj( $title, $title->getPrefixedText(), 'redirect=no' ); + $params[0] = $skin->makeLinkObj( Title::newFromText( $params[0] ), htmlspecialchars( $params[0] ) ); + $params[1] = $wgLang->timeanddate( $params[1] ); + break; default: $titleLink = $skin->makeLinkObj( $title ); } diff --git a/includes/SpecialLog.php b/includes/SpecialLog.php index e9dbf81bff..98bcf7cf87 100644 --- a/includes/SpecialLog.php +++ b/includes/SpecialLog.php @@ -405,8 +405,13 @@ class LogViewer { } # Suppress $comment from old entries, not needed and can contain incorrect links $comment = ''; + // Show unmerge link + } elseif ( $s->log_action == 'merge' ) { + $merge = SpecialPage::getTitleFor( 'Mergehistory' ); + $revert = '(' . $this->skin->makeKnownLinkObj( $merge, wfMsg('revertmerge'), + wfArrayToCGI( array('target' => $paramArray[0], 'dest' => $title->getPrefixedText() ) ) ) . ')'; } elseif ( wfRunHooks( 'LogLine', array( $s->log_type, $s->log_action, $title, $paramArray, &$comment, &$revert ) ) ) { - //wfDebug( "Invoked LogLine hook for " $s->log_type . ", " . $s->log_action . "\n" ); + // wfDebug( "Invoked LogLine hook for " $s->log_type . ", " . $s->log_action . "\n" ); // Do nothing. The implementation is handled by the hook modifiying the passed-by-ref parameters. } } diff --git a/includes/SpecialMergeHistory.php b/includes/SpecialMergeHistory.php new file mode 100644 index 0000000000..41ba8edd5a --- /dev/null +++ b/includes/SpecialMergeHistory.php @@ -0,0 +1,388 @@ +execute(); +} + +/** + * The HTML form for Special:MergeHistory, which allows users with the appropriate + * permissions to view and restore deleted content. + * @addtogroup SpecialPage + */ +class MergehistoryForm { + var $mAction, $mTarget, $mDest, $mTimestamp, $mTargetID, $mDestID, $mComment; + var $mTargetObj, $mDestObj; + + function MergehistoryForm( $request, $par = "" ) { + global $wgUser; + + $this->mAction = $request->getVal( 'action' ); + $this->mTarget = $request->getVal( 'target' ); + $this->mDest = $request->getVal( 'dest' ); + + $this->mTargetID = intval( $request->getVal( 'targetID' ) ); + $this->mDestID = intval( $request->getVal( 'destID' ) ); + $this->mTimestamp = $request->getVal( 'mergepoint' ); + $this->mComment = $request->getText( 'wpComment' ); + + $this->mMerge = $request->wasPosted() && $wgUser->matchEditToken( $request->getVal( 'wpEditToken' ) ); + // target page + if( $this->mTarget !== "" ) { + $this->mTargetObj = Title::newFromURL( $this->mTarget ); + } else { + $this->mTargetObj = NULL; + } + # Destination + if( $this->mDest !== "" ) { + $this->mDestObj = Title::newFromURL( $this->mDest ); + } else { + $this->mDestObj = NULL; + } + + $this->preCacheMessages(); + } + + /** + * As we use the same small set of messages in various methods and that + * they are called often, we call them once and save them in $this->message + */ + function preCacheMessages() { + // Precache various messages + if( !isset( $this->message ) ) { + $this->message['last'] = wfMsgExt( 'last', array( 'escape') ); + } + } + + function execute() { + global $wgOut, $wgUser; + + $wgOut->setPagetitle( wfMsgHtml( "mergehistory" ) ); + + if( $this->mTargetID && $this->mDestID && $this->mAction=="submit" && $this->mMerge ) { + return $this->merge(); + } + + if( is_object($this->mTargetObj) && is_object($this->mDestObj) ) { + return $this->showHistory(); + } + + return $this->showMergeForm(); + } + + function showMergeForm() { + global $wgOut, $wgScript; + + $wgOut->addWikiText( wfMsg( 'mergehistory-header' ) ); + + $wgOut->addHtml( + Xml::openElement( 'form', array( + 'method' => 'get', + 'action' => $wgScript ) ) . + '
' . + Xml::element( 'legend', array(), + wfMsg( 'mergehistory-box' ) ) . + Xml::hidden( 'title', + SpecialPage::getTitleFor( 'Mergehistory' )->getPrefixedDbKey() ) . + Xml::openElement( 'table' ) . + " + ".Xml::Label( wfMsg( 'mergehistory-from' ), 'target' )." + ".Xml::input( 'target', 30, $this->mTarget, array('id'=>'target') )." + + ".Xml::Label( wfMsg( 'mergehistory-into' ), 'dest' )." + ".Xml::input( 'dest', 30, $this->mDest, array('id'=>'dest') )." + " . + Xml::submitButton( wfMsg( 'mergehistory-go' ) ) . + "" . + Xml::closeElement( 'table' ) . + '
' . + '' ); + } + + private function showHistory() { + global $wgLang, $wgContLang, $wgUser, $wgOut; + + $this->sk = $wgUser->getSkin(); + + $wgOut->setPagetitle( wfMsg( "mergehistory" ) ); + + $this->showMergeForm(); + + # List all stored revisions + $revisions = new MergeHistoryPager( $this, array(), $this->mTargetObj, $this->mDestObj ); + $haveRevisions = $revisions && $revisions->getNumRows() > 0; + + $titleObj = SpecialPage::getTitleFor( "Mergehistory" ); + $action = $titleObj->getLocalURL( "action=submit" ); + # Start the form here + $top = Xml::openElement( 'form', array( 'method' => 'post', 'action' => $action, 'id' => 'merge' ) ); + $wgOut->addHtml( $top ); + + if( $haveRevisions ) { + # Format the user-visible controls (comment field, submission button) + # in a nice little table + $align = $wgContLang->isRtl() ? 'left' : 'right'; + $table = + Xml::openElement( 'fieldset' ) . + Xml::openElement( 'table' ) . + " + " . + wfMsgExt( 'mergehistory-merge', array('parseinline'), + $this->mTargetObj->getPrefixedText(), $this->mDestObj->getPrefixedText() ) . + " + + + " . + Xml::label( wfMsg( 'undeletecomment' ), 'wpComment' ) . + " + " . + Xml::input( 'wpComment', 50, $this->mComment ) . + " + + +   + " . + Xml::submitButton( wfMsg( 'mergehistory-submit' ), array( 'name' => 'merge', 'id' => 'mw-merge-submit' ) ) . + " + " . + Xml::closeElement( 'table' ) . + Xml::closeElement( 'fieldset' ); + + $wgOut->addHtml( $table ); + } + + $wgOut->addHTML( "

" . wfMsgHtml( "mergehistory-list" ) . "

\n" ); + + if( $haveRevisions ) { + $wgOut->addHTML( $revisions->getNavigationBar() ); + $wgOut->addHTML( "" ); + $wgOut->addHTML( $revisions->getNavigationBar() ); + } else { + $wgOut->addWikiText( wfMsg( "mergehistory-empty" ) ); + } + + # Show relevant lines from the deletion log: + $wgOut->addHTML( "

" . htmlspecialchars( LogPage::logName( 'merge' ) ) . "

\n" ); + $logViewer = new LogViewer( + new LogReader( + new FauxRequest( + array( 'page' => $this->mTargetObj->getPrefixedText(), + 'type' => 'merge' ) ) ) ); + $logViewer->showList( $wgOut ); + + # Slip in the hidden controls here + # When we submit, go by page ID to avoid some nasty but unlikely collisions. + # Such would happen if a page was renamed after the form loaded, but before submit + $misc = Xml::hidden( 'targetID', $this->mTargetObj->getArticleID() ); + $misc .= Xml::hidden( 'destID', $this->mDestObj->getArticleID() ); + $misc .= Xml::hidden( 'target', $this->mTarget ); + $misc .= Xml::hidden( 'dest', $this->mDest ); + $misc .= Xml::hidden( 'wpEditToken', $wgUser->editToken() ); + $misc .= Xml::closeElement( 'form' ); + $wgOut->addHtml( $misc ); + + return true; + } + + function formatRevisionRow( $row ) { + global $wgUser, $wgLang; + + $rev = new Revision( $row ); + + $stxt = ''; + $last = $this->message['last']; + + $ts = wfTimestamp( TS_MW, $row->rev_timestamp ); + $checkBox = wfRadio( "mergepoint", $ts, false ); + + $pageLink = $this->sk->makeKnownLinkObj( $rev->getTitle(), + $wgLang->timeanddate( $ts ), 'oldid=' . $rev->getID() ); + if( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $pageLink = '' . $pageLink . ''; + } + + # Last link + if( !$rev->userCan( Revision::DELETED_TEXT ) ) + $last = $this->message['last']; + else if( isset($this->prevId[$row->rev_id]) ) + $last = $this->sk->makeKnownLinkObj( $rev->getTitle(), $this->message['last'], + "&diff=" . $row->rev_id . "&oldid=" . $this->prevId[$row->rev_id] ); + + $userLink = $this->sk->revUserTools( $rev ); + + if(!is_null($size = $row->rev_len)) { + if($size == 0) + $stxt = wfMsgHtml('historyempty'); + else + $stxt = wfMsgHtml('historysize', $wgLang->formatNum( $size ) ); + } + $comment = $this->sk->revComment( $rev ); + + return "
  • $checkBox ($last) $pageLink . . $userLink $stxt $comment
  • "; + } + + /** + * Fetch revision text link if it's available to all users + * @return string + */ + function getPageLink( $row, $titleObj, $ts, $target ) { + global $wgLang; + + if( !$this->userCan($row, Revision::DELETED_TEXT) ) { + return '' . $wgLang->timeanddate( $ts, true ) . ''; + } else { + $link = $this->sk->makeKnownLinkObj( $titleObj, + $wgLang->timeanddate( $ts, true ), "target=$target×tamp=$ts" ); + if( $this->isDeleted($row, Revision::DELETED_TEXT) ) + $link = '' . $link . ''; + return $link; + } + } + + function merge() { + global $wgOut, $wgUser; + # Get the titles directly from the IDs, in case the target page params + # were spoofed. The queries are done based on the IDs, so it's best to + # keep it consistent... + $targetTitle = Title::newFromID( $this->mTargetID ); + $destTitle = Title::newFromID( $this->mDestID ); + if( is_null($targetTitle) || is_null($destTitle) ) + return false; // validate these + # Verify that this timestamp is valid + # Must be older than the destination page + $dbw = wfGetDB( DB_MASTER ); + $maxtimestamp = $dbw->selectField( 'revision', 'MIN(rev_timestamp)', + array('rev_page' => $this->mDestID ), + __METHOD__ ); + # Destination page must exist with revisions + if( !$maxtimestamp ) { + $wgOut->addWikiText( wfMsg('mergehistory-fail') ); + return false; + } + # Leave the latest version no matter what + $lasttime = $dbw->selectField( array('page','revision'), + 'rev_timestamp', + array('page_id' => $this->mTargetID, 'page_latest = rev_id' ), + __METHOD__ ); + # Take the most restrictive of the twain + $maxtimestamp = ($lasttime < $maxtimestamp) ? $lasttime : $maxtimestamp; + if( $this->mTimestamp >= $maxtimestamp ) { + $wgOut->addHtml( wfMsg('mergehistory-fail') ); + return false; + } + # Update the revisions + if( $this->mTimestamp ) + $timewhere = "rev_timestamp <= {$this->mTimestamp}"; + else + $timewhere = '1 = 1'; + + $dbw->update( 'revision', + array( 'rev_page' => $this->mDestID ), + array( 'rev_page' => $this->mTargetID, + "rev_timestamp < {$maxtimestamp}", + $timewhere ), + __METHOD__ ); + # Check if this did anything + $count = $dbw->affectedRows(); + if( !$count ) { + $wgOut->addHtml( wfMsg('mergehistory-fail') ); + return false; + } + # Update our logs + $log = new LogPage( 'merge' ); + $log->addEntry( 'merge', $targetTitle, $this->mComment, + array($destTitle->getPrefixedText(),$this->mTimestamp) ); + + $wgOut->addHtml( wfMsgExt( 'mergehistory-success', array('parseinline'), + $targetTitle->getPrefixedText(), $destTitle->getPrefixedText(), $count ) ); + + wfRunHooks( 'ArticleMergeComplete', array( $targetTitle, $destTitle ) ); + + return true; + } +} + +class MergeHistoryPager extends ReverseChronologicalPager { + public $mForm, $mConds; + + function __construct( $form, $conds = array(), $title, $title2 ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->title = $title; + $this->articleID = $title->getArticleID(); + + $dbr = wfGetDB( DB_SLAVE ); + $maxtimestamp = $dbr->selectField( 'revision', 'MIN(rev_timestamp)', + array('rev_page' => $title2->getArticleID() ), + __METHOD__ ); + $this->maxTimestamp = $maxtimestamp; + + parent::__construct(); + } + + function getStartBody() { + wfProfileIn( __METHOD__ ); + # Do a link batch query + $this->mResult->seek( 0 ); + $batch = new LinkBatch(); + # Give some pointers to make (last) links + $this->mForm->prevId = array(); + while( $row = $this->mResult->fetchObject() ) { + $batch->addObj( Title::makeTitleSafe( NS_USER, $row->rev_user_text ) ); + $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->rev_user_text ) ); + + $rev_id = isset($rev_id) ? $rev_id : $row->rev_id; + if( $rev_id > $row->rev_id ) + $this->mForm->prevId[$rev_id] = $row->rev_id; + else if( $rev_id < $row->rev_id ) + $this->mForm->prevId[$row->rev_id] = $rev_id; + + $rev_id = $row->rev_id; + } + + $batch->execute(); + $this->mResult->seek( 0 ); + + wfProfileOut( __METHOD__ ); + return ''; + } + + function formatRow( $row ) { + $block = new Block; + return $this->mForm->formatRevisionRow( $row ); + } + + function getQueryInfo() { + $conds = $this->mConds; + $conds['rev_page'] = $this->articleID; + $conds[] = "rev_timestamp < {$this->maxTimestamp}"; + # Skip the latest one, as that could cause problems + if( $page = $this->title->getLatestRevID() ) + $conds[] = "rev_id != {$page}"; + + return array( + 'tables' => array('revision'), + 'fields' => array( 'rev_minor_edit', 'rev_timestamp', 'rev_user', 'rev_user_text', 'rev_comment', + 'rev_id', 'rev_page', 'rev_text_id', 'rev_len', 'rev_deleted' ), + 'conds' => $conds + ); + } + + function getIndexField() { + return 'rev_timestamp'; + } +} diff --git a/includes/SpecialPage.php b/includes/SpecialPage.php index b09658cb99..6dd707f9ec 100644 --- a/includes/SpecialPage.php +++ b/includes/SpecialPage.php @@ -131,14 +131,14 @@ class SpecialPage 'Log' => array( 'SpecialPage', 'Log' ), 'Blockip' => array( 'SpecialPage', 'Blockip', 'block' ), 'Undelete' => array( 'SpecialPage', 'Undelete', 'deletedhistory' ), - 'Import' => array( 'SpecialPage', "Import", 'import' ), + 'Import' => array( 'SpecialPage', 'Import', 'import' ), 'Lockdb' => array( 'SpecialPage', 'Lockdb', 'siteadmin' ), 'Unlockdb' => array( 'SpecialPage', 'Unlockdb', 'siteadmin' ), 'Userrights' => array( 'SpecialPage', 'Userrights', 'userrights' ), 'MIMEsearch' => array( 'SpecialPage', 'MIMEsearch' ), 'Unwatchedpages' => array( 'SpecialPage', 'Unwatchedpages', 'unwatchedpages' ), 'Listredirects' => array( 'SpecialPage', 'Listredirects' ), - 'Revisiondelete' => array( 'SpecialPage', 'Revisiondelete', 'deleterevision' ), + 'Revisiondelete' => array( 'UnlistedSpecialPage', 'Revisiondelete', 'deleterevision' ), 'Unusedtemplates' => array( 'SpecialPage', 'Unusedtemplates' ), 'Randomredirect' => array( 'SpecialPage', 'Randomredirect' ), 'Withoutinterwiki' => array( 'SpecialPage', 'Withoutinterwiki' ), @@ -147,6 +147,7 @@ class SpecialPage 'Mytalk' => array( 'SpecialMytalk' ), 'Mycontributions' => array( 'SpecialMycontributions' ), 'Listadmins' => array( 'SpecialRedirectToSpecial', 'Listadmins', 'Listusers', 'sysop' ), + 'MergeHistory' => array( 'SpecialPage', 'MergeHistory', 'mergehistory' ), 'Listbots' => array( 'SpecialRedirectToSpecial', 'Listbots', 'Listusers', 'bot' ), ); diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index b5c616f08d..2b551acb88 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -1183,6 +1183,30 @@ undelete it again through this same interface, unless additional restrictions ar 'overlogpagetext' => 'Below is a list of the most recent deletions and blocks involving content hidden from Sysops. See the [[Special:Ipblocklist|IP block list]] for the list of currently operational bans and blocks.', +# History merging +'mergehistory' => 'Merge page histories', +'mergehistory-header' => "This page lets you merge revisions of the history of one source page into a newer page. +Make sure that this change will maintain historical page continuity. + +'''At least the current revision of the source page must remain.'''", +'mergehistory-box' => 'Merge revisions of two pages:', +'mergehistory-from' => 'Source page:', +'mergehistory-into' => 'Destination page:', +'mergehistory-list' => 'Mergeable edit history', +'mergehistory-merge' => 'The following revisions of [[:$1|$1]] can be merged into [[:$2|$2]]. Use the radio +button column to merge in only the revisions created at and before the specified time. Note that using the +navigation links will reset this column.', +'mergehistory-go' => 'Show mergeable edits', +'mergehistory-submit' => 'Merge revisions', +'mergehistory-empty' => 'No revisions can be merged', +'mergehistory-success' => '$3 revisions of [[:$1]] successfully merged into [[:$2]].', +'mergehistory-fail' => 'Unable to perform history merge, please recheck the page and time parameters.', + +'mergelog' => 'Merge log', +'pagemerge-logentry' => 'merged $1 into $2 (revisions up to $3)', +'revertmerge' => 'Unmerge', +'mergelogpagetext' => 'Below is a list of the most recent merges of one page history into another.', + # Diffs 'history-title' => 'Revision history of "$1"', 'difference' => '(Difference between revisions)', -- 2.20.1