SpecialMergeHistory.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <?php
  2. /**
  3. * Special page allowing users with the appropriate permissions to
  4. * merge article histories, with some restrictions
  5. *
  6. * @file
  7. * @ingroup SpecialPage
  8. */
  9. /**
  10. * Constructor
  11. */
  12. function wfSpecialMergehistory( $par ) {
  13. global $wgRequest;
  14. $form = new MergehistoryForm( $wgRequest, $par );
  15. $form->execute();
  16. }
  17. /**
  18. * The HTML form for Special:MergeHistory, which allows users with the appropriate
  19. * permissions to view and restore deleted content.
  20. * @ingroup SpecialPage
  21. */
  22. class MergehistoryForm {
  23. var $mAction, $mTarget, $mDest, $mTimestamp, $mTargetID, $mDestID, $mComment;
  24. var $mTargetObj, $mDestObj;
  25. function MergehistoryForm( $request, $par = "" ) {
  26. global $wgUser;
  27. $this->mAction = $request->getVal( 'action' );
  28. $this->mTarget = $request->getVal( 'target' );
  29. $this->mDest = $request->getVal( 'dest' );
  30. $this->mSubmitted = $request->getBool( 'submitted' );
  31. $this->mTargetID = intval( $request->getVal( 'targetID' ) );
  32. $this->mDestID = intval( $request->getVal( 'destID' ) );
  33. $this->mTimestamp = $request->getVal( 'mergepoint' );
  34. if( !preg_match("/[0-9]{14}/",$this->mTimestamp) ) {
  35. $this->mTimestamp = '';
  36. }
  37. $this->mComment = $request->getText( 'wpComment' );
  38. $this->mMerge = $request->wasPosted() && $wgUser->matchEditToken( $request->getVal( 'wpEditToken' ) );
  39. // target page
  40. if( $this->mSubmitted ) {
  41. $this->mTargetObj = Title::newFromURL( $this->mTarget );
  42. $this->mDestObj = Title::newFromURL( $this->mDest );
  43. } else {
  44. $this->mTargetObj = null;
  45. $this->mDestObj = null;
  46. }
  47. $this->preCacheMessages();
  48. }
  49. /**
  50. * As we use the same small set of messages in various methods and that
  51. * they are called often, we call them once and save them in $this->message
  52. */
  53. function preCacheMessages() {
  54. // Precache various messages
  55. if( !isset( $this->message ) ) {
  56. $this->message['last'] = wfMsgExt( 'last', array( 'escape') );
  57. }
  58. }
  59. function execute() {
  60. global $wgOut, $wgUser;
  61. $wgOut->setPagetitle( wfMsgHtml( "mergehistory" ) );
  62. if( $this->mTargetID && $this->mDestID && $this->mAction=="submit" && $this->mMerge ) {
  63. return $this->merge();
  64. }
  65. if ( !$this->mSubmitted ) {
  66. $this->showMergeForm();
  67. return;
  68. }
  69. $errors = array();
  70. if ( !$this->mTargetObj instanceof Title ) {
  71. $errors[] = wfMsgExt( 'mergehistory-invalid-source', array( 'parse' ) );
  72. } elseif( !$this->mTargetObj->exists() ) {
  73. $errors[] = wfMsgExt( 'mergehistory-no-source', array( 'parse' ),
  74. wfEscapeWikiText( $this->mTargetObj->getPrefixedText() )
  75. );
  76. }
  77. if ( !$this->mDestObj instanceof Title) {
  78. $errors[] = wfMsgExt( 'mergehistory-invalid-destination', array( 'parse' ) );
  79. } elseif( !$this->mDestObj->exists() ) {
  80. $errors[] = wfMsgExt( 'mergehistory-no-destination', array( 'parse' ),
  81. wfEscapeWikiText( $this->mDestObj->getPrefixedText() )
  82. );
  83. }
  84. if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) {
  85. $errors[] = wfMsgExt( 'mergehistory-same-destination', array( 'parse' ) );
  86. }
  87. if ( count( $errors ) ) {
  88. $this->showMergeForm();
  89. $wgOut->addHTML( implode( "\n", $errors ) );
  90. } else {
  91. $this->showHistory();
  92. }
  93. }
  94. function showMergeForm() {
  95. global $wgOut, $wgScript;
  96. $wgOut->addWikiMsg( 'mergehistory-header' );
  97. $wgOut->addHTML(
  98. Xml::openElement( 'form', array(
  99. 'method' => 'get',
  100. 'action' => $wgScript ) ) .
  101. '<fieldset>' .
  102. Xml::element( 'legend', array(),
  103. wfMsg( 'mergehistory-box' ) ) .
  104. Xml::hidden( 'title',
  105. SpecialPage::getTitleFor( 'Mergehistory' )->getPrefixedDbKey() ) .
  106. Xml::hidden( 'submitted', '1' ) .
  107. Xml::hidden( 'mergepoint', $this->mTimestamp ) .
  108. Xml::openElement( 'table' ) .
  109. "<tr>
  110. <td>".Xml::label( wfMsg( 'mergehistory-from' ), 'target' )."</td>
  111. <td>".Xml::input( 'target', 30, $this->mTarget, array('id'=>'target') )."</td>
  112. </tr><tr>
  113. <td>".Xml::label( wfMsg( 'mergehistory-into' ), 'dest' )."</td>
  114. <td>".Xml::input( 'dest', 30, $this->mDest, array('id'=>'dest') )."</td>
  115. </tr><tr><td>" .
  116. Xml::submitButton( wfMsg( 'mergehistory-go' ) ) .
  117. "</td></tr>" .
  118. Xml::closeElement( 'table' ) .
  119. '</fieldset>' .
  120. '</form>' );
  121. }
  122. private function showHistory() {
  123. global $wgLang, $wgUser, $wgOut;
  124. $this->sk = $wgUser->getSkin();
  125. $wgOut->setPagetitle( wfMsg( "mergehistory" ) );
  126. $this->showMergeForm();
  127. # List all stored revisions
  128. $revisions = new MergeHistoryPager( $this, array(), $this->mTargetObj, $this->mDestObj );
  129. $haveRevisions = $revisions && $revisions->getNumRows() > 0;
  130. $titleObj = SpecialPage::getTitleFor( "Mergehistory" );
  131. $action = $titleObj->getLocalURL( "action=submit" );
  132. # Start the form here
  133. $top = Xml::openElement( 'form', array( 'method' => 'post', 'action' => $action, 'id' => 'merge' ) );
  134. $wgOut->addHTML( $top );
  135. if( $haveRevisions ) {
  136. # Format the user-visible controls (comment field, submission button)
  137. # in a nice little table
  138. $table =
  139. Xml::openElement( 'fieldset' ) .
  140. wfMsgExt( 'mergehistory-merge', array('parseinline'),
  141. $this->mTargetObj->getPrefixedText(), $this->mDestObj->getPrefixedText() ) .
  142. Xml::openElement( 'table', array( 'id' => 'mw-mergehistory-table' ) ) .
  143. "<tr>
  144. <td class='mw-label'>" .
  145. Xml::label( wfMsg( 'mergehistory-reason' ), 'wpComment' ) .
  146. "</td>
  147. <td class='mw-input'>" .
  148. Xml::input( 'wpComment', 50, $this->mComment, array('id' => 'wpComment') ) .
  149. "</td>
  150. </tr>
  151. <tr>
  152. <td>&nbsp;</td>
  153. <td class='mw-submit'>" .
  154. Xml::submitButton( wfMsg( 'mergehistory-submit' ), array( 'name' => 'merge', 'id' => 'mw-merge-submit' ) ) .
  155. "</td>
  156. </tr>" .
  157. Xml::closeElement( 'table' ) .
  158. Xml::closeElement( 'fieldset' );
  159. $wgOut->addHTML( $table );
  160. }
  161. $wgOut->addHTML( "<h2 id=\"mw-mergehistory\">" . wfMsgHtml( "mergehistory-list" ) . "</h2>\n" );
  162. if( $haveRevisions ) {
  163. $wgOut->addHTML( $revisions->getNavigationBar() );
  164. $wgOut->addHTML( "<ul>" );
  165. $wgOut->addHTML( $revisions->getBody() );
  166. $wgOut->addHTML( "</ul>" );
  167. $wgOut->addHTML( $revisions->getNavigationBar() );
  168. } else {
  169. $wgOut->addWikiMsg( "mergehistory-empty" );
  170. }
  171. # Show relevant lines from the deletion log:
  172. $wgOut->addHTML( "<h2>" . htmlspecialchars( LogPage::logName( 'merge' ) ) . "</h2>\n" );
  173. LogEventsList::showLogExtract( $wgOut, 'merge', $this->mTargetObj->getPrefixedText() );
  174. # When we submit, go by page ID to avoid some nasty but unlikely collisions.
  175. # Such would happen if a page was renamed after the form loaded, but before submit
  176. $misc = Xml::hidden( 'targetID', $this->mTargetObj->getArticleID() );
  177. $misc .= Xml::hidden( 'destID', $this->mDestObj->getArticleID() );
  178. $misc .= Xml::hidden( 'target', $this->mTarget );
  179. $misc .= Xml::hidden( 'dest', $this->mDest );
  180. $misc .= Xml::hidden( 'wpEditToken', $wgUser->editToken() );
  181. $misc .= Xml::closeElement( 'form' );
  182. $wgOut->addHTML( $misc );
  183. return true;
  184. }
  185. function formatRevisionRow( $row ) {
  186. global $wgUser, $wgLang;
  187. $rev = new Revision( $row );
  188. $stxt = '';
  189. $last = $this->message['last'];
  190. $ts = wfTimestamp( TS_MW, $row->rev_timestamp );
  191. $checkBox = Xml::radio( "mergepoint", $ts, false );
  192. $pageLink = $this->sk->makeKnownLinkObj( $rev->getTitle(),
  193. htmlspecialchars( $wgLang->timeanddate( $ts ) ), 'oldid=' . $rev->getId() );
  194. if( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
  195. $pageLink = '<span class="history-deleted">' . $pageLink . '</span>';
  196. }
  197. # Last link
  198. if( !$rev->userCan( Revision::DELETED_TEXT ) )
  199. $last = $this->message['last'];
  200. else if( isset($this->prevId[$row->rev_id]) )
  201. $last = $this->sk->makeKnownLinkObj( $rev->getTitle(), $this->message['last'],
  202. "diff=" . $row->rev_id . "&oldid=" . $this->prevId[$row->rev_id] );
  203. $userLink = $this->sk->revUserTools( $rev );
  204. if(!is_null($size = $row->rev_len)) {
  205. $stxt = $this->sk->formatRevisionSize( $size );
  206. }
  207. $comment = $this->sk->revComment( $rev );
  208. return "<li>$checkBox ($last) $pageLink . . $userLink $stxt $comment</li>";
  209. }
  210. /**
  211. * Fetch revision text link if it's available to all users
  212. * @return string
  213. */
  214. function getPageLink( $row, $titleObj, $ts, $target ) {
  215. global $wgLang;
  216. if( !$this->userCan($row, Revision::DELETED_TEXT) ) {
  217. return '<span class="history-deleted">' . $wgLang->timeanddate( $ts, true ) . '</span>';
  218. } else {
  219. $link = $this->sk->makeKnownLinkObj( $titleObj,
  220. $wgLang->timeanddate( $ts, true ), "target=$target&timestamp=$ts" );
  221. if( $this->isDeleted($row, Revision::DELETED_TEXT) )
  222. $link = '<span class="history-deleted">' . $link . '</span>';
  223. return $link;
  224. }
  225. }
  226. function merge() {
  227. global $wgOut, $wgUser;
  228. # Get the titles directly from the IDs, in case the target page params
  229. # were spoofed. The queries are done based on the IDs, so it's best to
  230. # keep it consistent...
  231. $targetTitle = Title::newFromID( $this->mTargetID );
  232. $destTitle = Title::newFromID( $this->mDestID );
  233. if( is_null($targetTitle) || is_null($destTitle) )
  234. return false; // validate these
  235. if( $targetTitle->getArticleId() == $destTitle->getArticleId() )
  236. return false;
  237. # Verify that this timestamp is valid
  238. # Must be older than the destination page
  239. $dbw = wfGetDB( DB_MASTER );
  240. # Get timestamp into DB format
  241. $this->mTimestamp = $this->mTimestamp ? $dbw->timestamp($this->mTimestamp) : '';
  242. # Max timestamp should be min of destination page
  243. $maxtimestamp = $dbw->selectField( 'revision', 'MIN(rev_timestamp)',
  244. array('rev_page' => $this->mDestID ),
  245. __METHOD__ );
  246. # Destination page must exist with revisions
  247. if( !$maxtimestamp ) {
  248. $wgOut->addWikiMsg('mergehistory-fail');
  249. return false;
  250. }
  251. # Get the latest timestamp of the source
  252. $lasttimestamp = $dbw->selectField( array('page','revision'),
  253. 'rev_timestamp',
  254. array('page_id' => $this->mTargetID, 'page_latest = rev_id' ),
  255. __METHOD__ );
  256. # $this->mTimestamp must be older than $maxtimestamp
  257. if( $this->mTimestamp >= $maxtimestamp ) {
  258. $wgOut->addWikiMsg('mergehistory-fail');
  259. return false;
  260. }
  261. # Update the revisions
  262. if( $this->mTimestamp ) {
  263. $timewhere = "rev_timestamp <= {$this->mTimestamp}";
  264. $TimestampLimit = wfTimestamp(TS_MW,$this->mTimestamp);
  265. } else {
  266. $timewhere = "rev_timestamp <= {$maxtimestamp}";
  267. $TimestampLimit = wfTimestamp(TS_MW,$lasttimestamp);
  268. }
  269. # Do the moving...
  270. $dbw->update( 'revision',
  271. array( 'rev_page' => $this->mDestID ),
  272. array( 'rev_page' => $this->mTargetID,
  273. $timewhere ),
  274. __METHOD__ );
  275. $count = $dbw->affectedRows();
  276. # Make the source page a redirect if no revisions are left
  277. $haveRevisions = $dbw->selectField( 'revision',
  278. 'rev_timestamp',
  279. array( 'rev_page' => $this->mTargetID ),
  280. __METHOD__,
  281. array( 'FOR UPDATE' ) );
  282. if( !$haveRevisions ) {
  283. if( $this->mComment ) {
  284. $comment = wfMsgForContent( 'mergehistory-comment', $targetTitle->getPrefixedText(),
  285. $destTitle->getPrefixedText(), $this->mComment );
  286. } else {
  287. $comment = wfMsgForContent( 'mergehistory-autocomment', $targetTitle->getPrefixedText(),
  288. $destTitle->getPrefixedText() );
  289. }
  290. $mwRedir = MagicWord::get( 'redirect' );
  291. $redirectText = $mwRedir->getSynonym( 0 ) . ' [[' . $destTitle->getPrefixedText() . "]]\n";
  292. $redirectArticle = new Article( $targetTitle );
  293. $redirectRevision = new Revision( array(
  294. 'page' => $this->mTargetID,
  295. 'comment' => $comment,
  296. 'text' => $redirectText ) );
  297. $redirectRevision->insertOn( $dbw );
  298. $redirectArticle->updateRevisionOn( $dbw, $redirectRevision );
  299. # Now, we record the link from the redirect to the new title.
  300. # It should have no other outgoing links...
  301. $dbw->delete( 'pagelinks', array( 'pl_from' => $this->mDestID ), __METHOD__ );
  302. $dbw->insert( 'pagelinks',
  303. array(
  304. 'pl_from' => $this->mDestID,
  305. 'pl_namespace' => $destTitle->getNamespace(),
  306. 'pl_title' => $destTitle->getDBkey() ),
  307. __METHOD__ );
  308. } else {
  309. $targetTitle->invalidateCache(); // update histories
  310. }
  311. $destTitle->invalidateCache(); // update histories
  312. # Check if this did anything
  313. if( !$count ) {
  314. $wgOut->addWikiMsg('mergehistory-fail');
  315. return false;
  316. }
  317. # Update our logs
  318. $log = new LogPage( 'merge' );
  319. $log->addEntry( 'merge', $targetTitle, $this->mComment,
  320. array($destTitle->getPrefixedText(),$TimestampLimit) );
  321. $wgOut->addHTML( wfMsgExt( 'mergehistory-success', array('parseinline'),
  322. $targetTitle->getPrefixedText(), $destTitle->getPrefixedText(), $count ) );
  323. wfRunHooks( 'ArticleMergeComplete', array( $targetTitle, $destTitle ) );
  324. return true;
  325. }
  326. }
  327. class MergeHistoryPager extends ReverseChronologicalPager {
  328. public $mForm, $mConds;
  329. function __construct( $form, $conds = array(), $source, $dest ) {
  330. $this->mForm = $form;
  331. $this->mConds = $conds;
  332. $this->title = $source;
  333. $this->articleID = $source->getArticleID();
  334. $dbr = wfGetDB( DB_SLAVE );
  335. $maxtimestamp = $dbr->selectField( 'revision', 'MIN(rev_timestamp)',
  336. array('rev_page' => $dest->getArticleID() ),
  337. __METHOD__ );
  338. $this->maxTimestamp = $maxtimestamp;
  339. parent::__construct();
  340. }
  341. function getStartBody() {
  342. wfProfileIn( __METHOD__ );
  343. # Do a link batch query
  344. $this->mResult->seek( 0 );
  345. $batch = new LinkBatch();
  346. # Give some pointers to make (last) links
  347. $this->mForm->prevId = array();
  348. while( $row = $this->mResult->fetchObject() ) {
  349. $batch->addObj( Title::makeTitleSafe( NS_USER, $row->rev_user_text ) );
  350. $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->rev_user_text ) );
  351. $rev_id = isset($rev_id) ? $rev_id : $row->rev_id;
  352. if( $rev_id > $row->rev_id )
  353. $this->mForm->prevId[$rev_id] = $row->rev_id;
  354. else if( $rev_id < $row->rev_id )
  355. $this->mForm->prevId[$row->rev_id] = $rev_id;
  356. $rev_id = $row->rev_id;
  357. }
  358. $batch->execute();
  359. $this->mResult->seek( 0 );
  360. wfProfileOut( __METHOD__ );
  361. return '';
  362. }
  363. function formatRow( $row ) {
  364. $block = new Block;
  365. return $this->mForm->formatRevisionRow( $row );
  366. }
  367. function getQueryInfo() {
  368. $conds = $this->mConds;
  369. $conds['rev_page'] = $this->articleID;
  370. $conds[] = 'page_id = rev_page';
  371. $conds[] = "rev_timestamp < {$this->maxTimestamp}";
  372. return array(
  373. 'tables' => array('revision','page'),
  374. 'fields' => array( 'rev_minor_edit', 'rev_timestamp', 'rev_user', 'rev_user_text', 'rev_comment',
  375. 'rev_id', 'rev_page', 'rev_parent_id', 'rev_text_id', 'rev_len', 'rev_deleted' ),
  376. 'conds' => $conds
  377. );
  378. }
  379. function getIndexField() {
  380. return 'rev_timestamp';
  381. }
  382. }