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