Merge "Avoid DBPerformance log warnings in SpecialEditWatchlist"
[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
28 /**
29 * Handles the backend logic of merging the histories of two
30 * pages.
31 *
32 * @since 1.27
33 */
34 class MergeHistory {
35
36 /** @const int Maximum number of revisions that can be merged at once */
37 const REVISION_LIMIT = 5000;
38
39 /** @var Title Page from which history will be merged */
40 protected $source;
41
42 /** @var Title Page to which history will be merged */
43 protected $dest;
44
45 /** @var IDatabase Database that we are using */
46 protected $dbw;
47
48 /** @var MWTimestamp Maximum timestamp that we can use (oldest timestamp of dest) */
49 protected $maxTimestamp;
50
51 /** @var string SQL WHERE condition that selects source revisions to insert into destination */
52 protected $timeWhere;
53
54 /** @var MWTimestamp|bool Timestamp upto which history from the source will be merged */
55 protected $timestampLimit;
56
57 /** @var integer Number of revisions merged (for Special:MergeHistory success message) */
58 protected $revisionsMerged;
59
60 /**
61 * MergeHistory constructor.
62 * @param Title $source Page from which history will be merged
63 * @param Title $dest Page to which history will be merged
64 * @param string|bool $timestamp Timestamp up to which history from the source will be merged
65 */
66 public function __construct( Title $source, Title $dest, $timestamp = false ) {
67 // Save the parameters
68 $this->source = $source;
69 $this->dest = $dest;
70
71 // Get the database
72 $this->dbw = wfGetDB( DB_MASTER );
73
74 // Max timestamp should be min of destination page
75 $firstDestTimestamp = $this->dbw->selectField(
76 'revision',
77 'MIN(rev_timestamp)',
78 [ 'rev_page' => $this->dest->getArticleID() ],
79 __METHOD__
80 );
81 $this->maxTimestamp = new MWTimestamp( $firstDestTimestamp );
82
83 // Get the timestamp pivot condition
84 try {
85 if ( $timestamp ) {
86 // If we have a requested timestamp, use the
87 // latest revision up to that point as the insertion point
88 $mwTimestamp = new MWTimestamp( $timestamp );
89 $lastWorkingTimestamp = $this->dbw->selectField(
90 'revision',
91 'MAX(rev_timestamp)',
92 [
93 'rev_timestamp <= ' . $this->dbw->timestamp( $mwTimestamp ),
94 'rev_page' => $this->source->getArticleID()
95 ],
96 __METHOD__
97 );
98 $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
99
100 $timeInsert = $mwLastWorkingTimestamp;
101 $this->timestampLimit = $mwLastWorkingTimestamp;
102 } else {
103 // If we don't, merge entire source page history into the
104 // beginning of destination page history
105
106 // Get the latest timestamp of the source
107 $lastSourceTimestamp = $this->dbw->selectField(
108 [ 'page', 'revision' ],
109 'rev_timestamp',
110 [ 'page_id' => $this->source->getArticleID(),
111 'page_latest = rev_id'
112 ],
113 __METHOD__
114 );
115 $lasttimestamp = new MWTimestamp( $lastSourceTimestamp );
116
117 $timeInsert = $this->maxTimestamp;
118 $this->timestampLimit = $lasttimestamp;
119 }
120
121 $this->timeWhere = "rev_timestamp <= {$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 call_user_func_array( [ $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 if ( !$user->isAllowed( 'mergehistory' ) ) {
182 // User doesn't have the right to merge histories
183 $status->fatal( 'mergehistory-fail-permission' );
184 }
185
186 return $status;
187 }
188
189 /**
190 * Does various sanity checks that the merge is
191 * valid. Only things based on the two pages
192 * should be checked here.
193 *
194 * @return Status
195 */
196 public function isValidMerge() {
197 $status = new Status();
198
199 // If either article ID is 0, then revisions cannot be reliably selected
200 if ( $this->source->getArticleID() === 0 ) {
201 $status->fatal( 'mergehistory-fail-invalid-source' );
202 }
203 if ( $this->dest->getArticleID() === 0 ) {
204 $status->fatal( 'mergehistory-fail-invalid-dest' );
205 }
206
207 // Make sure page aren't the same
208 if ( $this->source->equals( $this->dest ) ) {
209 $status->fatal( 'mergehistory-fail-self-merge' );
210 }
211
212 // Make sure the timestamp is valid
213 if ( !$this->timestampLimit ) {
214 $status->fatal( 'mergehistory-fail-bad-timestamp' );
215 }
216
217 // $this->timestampLimit must be older than $this->maxTimestamp
218 if ( $this->timestampLimit > $this->maxTimestamp ) {
219 $status->fatal( 'mergehistory-fail-timestamps-overlap' );
220 }
221
222 // Check that there are not too many revisions to move
223 if ( $this->timestampLimit && $this->getRevisionCount() > self::REVISION_LIMIT ) {
224 $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
225 }
226
227 return $status;
228 }
229
230 /**
231 * Actually attempt the history move
232 *
233 * @todo if all versions of page A are moved to B and then a user
234 * tries to do a reverse-merge via the "unmerge" log link, then page
235 * A will still be a redirect (as it was after the original merge),
236 * though it will have the old revisions back from before (as expected).
237 * The user may have to "undo" the redirect manually to finish the "unmerge".
238 * Maybe this should delete redirects at the source page of merges?
239 *
240 * @param User $user
241 * @param string $reason
242 * @return Status status of the history merge
243 */
244 public function merge( User $user, $reason = '' ) {
245 $status = new Status();
246
247 // Check validity and permissions required for merge
248 $validCheck = $this->isValidMerge(); // Check this first to check for null pages
249 if ( !$validCheck->isOK() ) {
250 return $validCheck;
251 }
252 $permCheck = $this->checkPermissions( $user, $reason );
253 if ( !$permCheck->isOK() ) {
254 return $permCheck;
255 }
256
257 $this->dbw->update(
258 'revision',
259 [ 'rev_page' => $this->dest->getArticleID() ],
260 [ 'rev_page' => $this->source->getArticleID(), $this->timeWhere ],
261 __METHOD__
262 );
263
264 // Check if this did anything
265 $this->revisionsMerged = $this->dbw->affectedRows();
266 if ( $this->revisionsMerged < 1 ) {
267 $status->fatal( 'mergehistory-fail-no-change' );
268 return $status;
269 }
270
271 // Make the source page a redirect if no revisions are left
272 $haveRevisions = $this->dbw->selectField(
273 'revision',
274 'rev_timestamp',
275 [ 'rev_page' => $this->source->getArticleID() ],
276 __METHOD__,
277 [ 'FOR UPDATE' ]
278 );
279 if ( !$haveRevisions ) {
280 if ( $reason ) {
281 $reason = wfMessage(
282 'mergehistory-comment',
283 $this->source->getPrefixedText(),
284 $this->dest->getPrefixedText(),
285 $reason
286 )->inContentLanguage()->text();
287 } else {
288 $reason = wfMessage(
289 'mergehistory-autocomment',
290 $this->source->getPrefixedText(),
291 $this->dest->getPrefixedText()
292 )->inContentLanguage()->text();
293 }
294
295 $contentHandler = ContentHandler::getForTitle( $this->source );
296 $redirectContent = $contentHandler->makeRedirectContent(
297 $this->dest,
298 wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain()
299 );
300
301 if ( $redirectContent ) {
302 $redirectPage = WikiPage::factory( $this->source );
303 $redirectRevision = new Revision( [
304 'title' => $this->source,
305 'page' => $this->source->getArticleID(),
306 'comment' => $reason,
307 'content' => $redirectContent ] );
308 $redirectRevision->insertOn( $this->dbw );
309 $redirectPage->updateRevisionOn( $this->dbw, $redirectRevision );
310
311 // Now, we record the link from the redirect to the new title.
312 // It should have no other outgoing links...
313 $this->dbw->delete(
314 'pagelinks',
315 [ 'pl_from' => $this->dest->getArticleID() ],
316 __METHOD__
317 );
318 $this->dbw->insert( 'pagelinks',
319 [
320 'pl_from' => $this->dest->getArticleID(),
321 'pl_from_namespace' => $this->dest->getNamespace(),
322 'pl_namespace' => $this->dest->getNamespace(),
323 'pl_title' => $this->dest->getDBkey() ],
324 __METHOD__
325 );
326 } else {
327 // Warning if we couldn't create the redirect
328 $status->warning( 'mergehistory-warning-redirect-not-created' );
329 }
330 } else {
331 $this->source->invalidateCache(); // update histories
332 }
333 $this->dest->invalidateCache(); // update histories
334
335 // Update our logs
336 $logEntry = new ManualLogEntry( 'merge', 'merge' );
337 $logEntry->setPerformer( $user );
338 $logEntry->setComment( $reason );
339 $logEntry->setTarget( $this->source );
340 $logEntry->setParameters( [
341 '4::dest' => $this->dest->getPrefixedText(),
342 '5::mergepoint' => $this->timestampLimit->getTimestamp( TS_MW )
343 ] );
344 $logId = $logEntry->insert();
345 $logEntry->publish( $logId );
346
347 Hooks::run( 'ArticleMergeComplete', [ $this->source, $this->dest ] );
348
349 return $status;
350 }
351 }