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