MergeHistory.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <?php
  2. /**
  3. * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. */
  22. use MediaWiki\MediaWikiServices;
  23. use Wikimedia\Timestamp\TimestampException;
  24. use Wikimedia\Rdbms\IDatabase;
  25. /**
  26. * Handles the backend logic of merging the histories of two
  27. * pages.
  28. *
  29. * @since 1.27
  30. */
  31. class MergeHistory {
  32. /** @const int Maximum number of revisions that can be merged at once */
  33. const REVISION_LIMIT = 5000;
  34. /** @var Title Page from which history will be merged */
  35. protected $source;
  36. /** @var Title Page to which history will be merged */
  37. protected $dest;
  38. /** @var IDatabase Database that we are using */
  39. protected $dbw;
  40. /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
  41. protected $maxTimestamp;
  42. /** @var string SQL WHERE condition that selects source revisions to insert into destination */
  43. protected $timeWhere;
  44. /** @var MWTimestamp|bool Timestamp upto which history from the source will be merged */
  45. protected $timestampLimit;
  46. /** @var int Number of revisions merged (for Special:MergeHistory success message) */
  47. protected $revisionsMerged;
  48. /**
  49. * @param Title $source Page from which history will be merged
  50. * @param Title $dest Page to which history will be merged
  51. * @param string|bool $timestamp Timestamp up to which history from the source will be merged
  52. */
  53. public function __construct( Title $source, Title $dest, $timestamp = false ) {
  54. // Save the parameters
  55. $this->source = $source;
  56. $this->dest = $dest;
  57. // Get the database
  58. $this->dbw = wfGetDB( DB_MASTER );
  59. // Max timestamp should be min of destination page
  60. $firstDestTimestamp = $this->dbw->selectField(
  61. 'revision',
  62. 'MIN(rev_timestamp)',
  63. [ 'rev_page' => $this->dest->getArticleID() ],
  64. __METHOD__
  65. );
  66. $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
  67. // Get the timestamp pivot condition
  68. try {
  69. if ( $timestamp ) {
  70. // If we have a requested timestamp, use the
  71. // latest revision up to that point as the insertion point
  72. $mwTimestamp = new MWTimestamp( $timestamp );
  73. $lastWorkingTimestamp = $this->dbw->selectField(
  74. 'revision',
  75. 'MAX(rev_timestamp)',
  76. [
  77. 'rev_timestamp <= ' .
  78. $this->dbw->addQuotes( $this->dbw->timestamp( $mwTimestamp ) ),
  79. 'rev_page' => $this->source->getArticleID()
  80. ],
  81. __METHOD__
  82. );
  83. $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
  84. $timeInsert = $mwLastWorkingTimestamp;
  85. $this->timestampLimit = $mwLastWorkingTimestamp;
  86. } else {
  87. // If we don't, merge entire source page history into the
  88. // beginning of destination page history
  89. // Get the latest timestamp of the source
  90. $lastSourceTimestamp = $this->dbw->selectField(
  91. [ 'page', 'revision' ],
  92. 'rev_timestamp',
  93. [ 'page_id' => $this->source->getArticleID(),
  94. 'page_latest = rev_id'
  95. ],
  96. __METHOD__
  97. );
  98. $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
  99. $timeInsert = $this->maxTimestamp;
  100. $this->timestampLimit = $lasttimestamp;
  101. }
  102. $this->timeWhere = "rev_timestamp <= " .
  103. $this->dbw->addQuotes( $this->dbw->timestamp( $timeInsert ) );
  104. } catch ( TimestampException $ex ) {
  105. // The timestamp we got is screwed up and merge cannot continue
  106. // This should be detected by $this->isValidMerge()
  107. $this->timestampLimit = false;
  108. }
  109. }
  110. /**
  111. * Get the number of revisions that will be moved
  112. * @return int
  113. */
  114. public function getRevisionCount() {
  115. $count = $this->dbw->selectRowCount( 'revision', '1',
  116. [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
  117. __METHOD__,
  118. [ 'LIMIT' => self::REVISION_LIMIT + 1 ]
  119. );
  120. return $count;
  121. }
  122. /**
  123. * Get the number of revisions that were moved
  124. * Used in the SpecialMergeHistory success message
  125. * @return int
  126. */
  127. public function getMergedRevisionCount() {
  128. return $this->revisionsMerged;
  129. }
  130. /**
  131. * Check if the merge is possible
  132. * @param User $user
  133. * @param string $reason
  134. * @return Status
  135. */
  136. public function checkPermissions( User $user, $reason ) {
  137. $status = new Status();
  138. // Check if user can edit both pages
  139. $errors = wfMergeErrorArrays(
  140. $this->source->getUserPermissionsErrors( 'edit', $user ),
  141. $this->dest->getUserPermissionsErrors( 'edit', $user )
  142. );
  143. // Convert into a Status object
  144. if ( $errors ) {
  145. foreach ( $errors as $error ) {
  146. call_user_func_array( [ $status, 'fatal' ], $error );
  147. }
  148. }
  149. // Anti-spam
  150. if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
  151. // This is kind of lame, won't display nice
  152. $status->fatal( 'spamprotectiontext' );
  153. }
  154. // Check mergehistory permission
  155. if ( !$user->isAllowed( 'mergehistory' ) ) {
  156. // User doesn't have the right to merge histories
  157. $status->fatal( 'mergehistory-fail-permission' );
  158. }
  159. return $status;
  160. }
  161. /**
  162. * Does various sanity checks that the merge is
  163. * valid. Only things based on the two pages
  164. * should be checked here.
  165. *
  166. * @return Status
  167. */
  168. public function isValidMerge() {
  169. $status = new Status();
  170. // If either article ID is 0, then revisions cannot be reliably selected
  171. if ( $this->source->getArticleID() === 0 ) {
  172. $status->fatal( 'mergehistory-fail-invalid-source' );
  173. }
  174. if ( $this->dest->getArticleID() === 0 ) {
  175. $status->fatal( 'mergehistory-fail-invalid-dest' );
  176. }
  177. // Make sure page aren't the same
  178. if ( $this->source->equals( $this->dest ) ) {
  179. $status->fatal( 'mergehistory-fail-self-merge' );
  180. }
  181. // Make sure the timestamp is valid
  182. if ( !$this->timestampLimit ) {
  183. $status->fatal( 'mergehistory-fail-bad-timestamp' );
  184. }
  185. // $this->timestampLimit must be older than $this->maxTimestamp
  186. if ( $this->timestampLimit > $this->maxTimestamp ) {
  187. $status->fatal( 'mergehistory-fail-timestamps-overlap' );
  188. }
  189. // Check that there are not too many revisions to move
  190. if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
  191. $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
  192. }
  193. return $status;
  194. }
  195. /**
  196. * Actually attempt the history move
  197. *
  198. * @todo if all versions of page A are moved to B and then a user
  199. * tries to do a reverse-merge via the "unmerge" log link, then page
  200. * A will still be a redirect (as it was after the original merge),
  201. * though it will have the old revisions back from before (as expected).
  202. * The user may have to "undo" the redirect manually to finish the "unmerge".
  203. * Maybe this should delete redirects at the source page of merges?
  204. *
  205. * @param User $user
  206. * @param string $reason
  207. * @return Status status of the history merge
  208. */
  209. public function merge( User $user, $reason = '' ) {
  210. $status = new Status();
  211. // Check validity and permissions required for merge
  212. $validCheck = $this->isValidMerge(); // Check this first to check for null pages
  213. if ( !$validCheck->isOK() ) {
  214. return $validCheck;
  215. }
  216. $permCheck = $this->checkPermissions( $user, $reason );
  217. if ( !$permCheck->isOK() ) {
  218. return $permCheck;
  219. }
  220. $this->dbw->update(
  221. 'revision',
  222. [ 'rev_page' => $this->dest->getArticleID() ],
  223. [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
  224. __METHOD__
  225. );
  226. // Check if this did anything
  227. $this->revisionsMerged = $this->dbw->affectedRows();
  228. if ( $this->revisionsMerged < 1 ) {
  229. $status->fatal( 'mergehistory-fail-no-change' );
  230. return $status;
  231. }
  232. // Make the source page a redirect if no revisions are left
  233. $haveRevisions = $this->dbw->selectField(
  234. 'revision',
  235. 'rev_timestamp',
  236. [ 'rev_page' => $this->source->getArticleID() ],
  237. __METHOD__,
  238. [ 'FOR UPDATE' ]
  239. );
  240. if ( !$haveRevisions ) {
  241. if ( $reason ) {
  242. $reason = wfMessage(
  243. 'mergehistory-comment',
  244. $this->source->getPrefixedText(),
  245. $this->dest->getPrefixedText(),
  246. $reason
  247. )->inContentLanguage()->text();
  248. } else {
  249. $reason = wfMessage(
  250. 'mergehistory-autocomment',
  251. $this->source->getPrefixedText(),
  252. $this->dest->getPrefixedText()
  253. )->inContentLanguage()->text();
  254. }
  255. $contentHandler = ContentHandler::getForTitle( $this->source );
  256. $redirectContent = $contentHandler->makeRedirectContent(
  257. $this->dest,
  258. wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
  259. );
  260. if ( $redirectContent ) {
  261. $redirectPage = WikiPage::factory( $this->source );
  262. $redirectRevision = new Revision( [
  263. 'title' => $this->source,
  264. 'page' => $this->source->getArticleID(),
  265. 'comment' => $reason,
  266. 'content' => $redirectContent ] );
  267. $redirectRevision->insertOn( $this->dbw );
  268. $redirectPage->updateRevisionOn( $this->dbw, $redirectRevision );
  269. // Now, we record the link from the redirect to the new title.
  270. // It should have no other outgoing links...
  271. $this->dbw->delete(
  272. 'pagelinks',
  273. [ 'pl_from' => $this->dest->getArticleID() ],
  274. __METHOD__
  275. );
  276. $this->dbw->insert( 'pagelinks',
  277. [
  278. 'pl_from' => $this->dest->getArticleID(),
  279. 'pl_from_namespace' => $this->dest->getNamespace(),
  280. 'pl_namespace' => $this->dest->getNamespace(),
  281. 'pl_title' => $this->dest->getDBkey() ],
  282. __METHOD__
  283. );
  284. } else {
  285. // Warning if we couldn't create the redirect
  286. $status->warning( 'mergehistory-warning-redirect-not-created' );
  287. }
  288. } else {
  289. $this->source->invalidateCache(); // update histories
  290. }
  291. $this->dest->invalidateCache(); // update histories
  292. // Duplicate watchers of the old article to the new article on history merge
  293. $store = MediaWikiServices::getInstance()->getWatchedItemStore();
  294. $store->duplicateAllAssociatedEntries( $this->source, $this->dest );
  295. // Update our logs
  296. $logEntry = new ManualLogEntry( 'merge', 'merge' );
  297. $logEntry->setPerformer( $user );
  298. $logEntry->setComment( $reason );
  299. $logEntry->setTarget( $this->source );
  300. $logEntry->setParameters( [
  301. '4::dest' => $this->dest->getPrefixedText(),
  302. '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
  303. ] );
  304. $logId = $logEntry->insert();
  305. $logEntry->publish( $logId );
  306. Hooks::run( 'ArticleMergeComplete', [ $this->source, $this->dest ] );
  307. return $status;
  308. }
  309. }