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