]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/MergeHistory.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.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 }