Merge "Add SPARQL client to core"
[lhc/web/wiklou.git] / includes / api / ApiComparePages.php
1 <?php
2 /**
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License along
15 * with this program; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 * http://www.gnu.org/copyleft/gpl.html
18 *
19 * @file
20 */
21
22 class ApiComparePages extends ApiBase {
23
24 private $guessed = false, $guessedTitle, $guessedModel, $props;
25
26 public function execute() {
27 $params = $this->extractRequestParams();
28
29 // Parameter validation
30 $this->requireAtLeastOneParameter( $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext' );
31 $this->requireAtLeastOneParameter( $params, 'totitle', 'toid', 'torev', 'totext', 'torelative' );
32
33 $this->props = array_flip( $params['prop'] );
34
35 // Cache responses publicly by default. This may be overridden later.
36 $this->getMain()->setCacheMode( 'public' );
37
38 // Get the 'from' Revision and Content
39 list( $fromRev, $fromContent, $relRev ) = $this->getDiffContent( 'from', $params );
40
41 // Get the 'to' Revision and Content
42 if ( $params['torelative'] !== null ) {
43 if ( !$relRev ) {
44 $this->dieWithError( 'apierror-compare-relative-to-nothing' );
45 }
46 switch ( $params['torelative'] ) {
47 case 'prev':
48 // Swap 'from' and 'to'
49 $toRev = $fromRev;
50 $toContent = $fromContent;
51 $fromRev = $relRev->getPrevious();
52 $fromContent = $fromRev
53 ? $fromRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
54 : $toContent->getContentHandler()->makeEmptyContent();
55 if ( !$fromContent ) {
56 $this->dieWithError(
57 [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent'
58 );
59 }
60 break;
61
62 case 'next':
63 $toRev = $relRev->getNext();
64 $toContent = $toRev
65 ? $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
66 : $fromContent;
67 if ( !$toContent ) {
68 $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
69 }
70 break;
71
72 case 'cur':
73 $title = $relRev->getTitle();
74 $id = $title->getLatestRevID();
75 $toRev = $id ? Revision::newFromId( $id ) : null;
76 if ( !$toRev ) {
77 $this->dieWithError(
78 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
79 );
80 }
81 $toContent = $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
82 if ( !$toContent ) {
83 $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
84 }
85 break;
86 }
87 $relRev2 = null;
88 } else {
89 list( $toRev, $toContent, $relRev2 ) = $this->getDiffContent( 'to', $params );
90 }
91
92 // Should never happen, but just in case...
93 if ( !$fromContent || !$toContent ) {
94 $this->dieWithError( 'apierror-baddiff' );
95 }
96
97 // Extract sections, if told to
98 if ( isset( $params['fromsection'] ) ) {
99 $fromContent = $fromContent->getSection( $params['fromsection'] );
100 if ( !$fromContent ) {
101 $this->dieWithError(
102 [ 'apierror-compare-nosuchfromsection', wfEscapeWikiText( $params['fromsection'] ) ],
103 'nosuchfromsection'
104 );
105 }
106 }
107 if ( isset( $params['tosection'] ) ) {
108 $toContent = $toContent->getSection( $params['tosection'] );
109 if ( !$toContent ) {
110 $this->dieWithError(
111 [ 'apierror-compare-nosuchtosection', wfEscapeWikiText( $params['tosection'] ) ],
112 'nosuchtosection'
113 );
114 }
115 }
116
117 // Get the diff
118 $context = new DerivativeContext( $this->getContext() );
119 if ( $relRev && $relRev->getTitle() ) {
120 $context->setTitle( $relRev->getTitle() );
121 } elseif ( $relRev2 && $relRev2->getTitle() ) {
122 $context->setTitle( $relRev2->getTitle() );
123 } else {
124 $this->guessTitleAndModel();
125 if ( $this->guessedTitle ) {
126 $context->setTitle( $this->guessedTitle );
127 }
128 }
129 $de = $fromContent->getContentHandler()->createDifferenceEngine(
130 $context,
131 $fromRev ? $fromRev->getId() : 0,
132 $toRev ? $toRev->getId() : 0,
133 /* $rcid = */ null,
134 /* $refreshCache = */ false,
135 /* $unhide = */ true
136 );
137 $de->setContent( $fromContent, $toContent );
138 $difftext = $de->getDiffBody();
139 if ( $difftext === false ) {
140 $this->dieWithError( 'apierror-baddiff' );
141 }
142
143 // Fill in the response
144 $vals = [];
145 $this->setVals( $vals, 'from', $fromRev );
146 $this->setVals( $vals, 'to', $toRev );
147
148 if ( isset( $this->props['rel'] ) ) {
149 if ( $fromRev ) {
150 $rev = $fromRev->getPrevious();
151 if ( $rev ) {
152 $vals['prev'] = $rev->getId();
153 }
154 }
155 if ( $toRev ) {
156 $rev = $toRev->getNext();
157 if ( $rev ) {
158 $vals['next'] = $rev->getId();
159 }
160 }
161 }
162
163 if ( isset( $this->props['diffsize'] ) ) {
164 $vals['diffsize'] = strlen( $difftext );
165 }
166 if ( isset( $this->props['diff'] ) ) {
167 ApiResult::setContentValue( $vals, 'body', $difftext );
168 }
169
170 // Diffs can be really big and there's little point in having
171 // ApiResult truncate it to an empty response since the diff is the
172 // whole reason this module exists. So pass NO_SIZE_CHECK here.
173 $this->getResult()->addValue( null, $this->getModuleName(), $vals, ApiResult::NO_SIZE_CHECK );
174 }
175
176 /**
177 * Guess an appropriate default Title and content model for this request
178 *
179 * Fills in $this->guessedTitle based on the first of 'fromrev',
180 * 'fromtitle', 'fromid', 'torev', 'totitle', and 'toid' that's present and
181 * valid.
182 *
183 * Fills in $this->guessedModel based on the Revision or Title used to
184 * determine $this->guessedTitle, or the 'fromcontentmodel' or
185 * 'tocontentmodel' parameters if no title was guessed.
186 */
187 private function guessTitleAndModel() {
188 if ( $this->guessed ) {
189 return;
190 }
191
192 $this->guessed = true;
193 $params = $this->extractRequestParams();
194
195 foreach ( [ 'from', 'to' ] as $prefix ) {
196 if ( $params["{$prefix}rev"] !== null ) {
197 $revId = $params["{$prefix}rev"];
198 $rev = Revision::newFromId( $revId );
199 if ( !$rev ) {
200 // Titles of deleted revisions aren't secret, per T51088
201 $arQuery = Revision::getArchiveQueryInfo();
202 $row = $this->getDB()->selectRow(
203 $arQuery['tables'],
204 array_merge(
205 $arQuery['fields'],
206 [ 'ar_namespace', 'ar_title' ]
207 ),
208 [ 'ar_rev_id' => $revId ],
209 __METHOD__,
210 [],
211 $arQuery['joins']
212 );
213 if ( $row ) {
214 $rev = Revision::newFromArchiveRow( $row );
215 }
216 }
217 if ( $rev ) {
218 $this->guessedTitle = $rev->getTitle();
219 $this->guessedModel = $rev->getContentModel();
220 break;
221 }
222 }
223
224 if ( $params["{$prefix}title"] !== null ) {
225 $title = Title::newFromText( $params["{$prefix}title"] );
226 if ( $title && !$title->isExternal() ) {
227 $this->guessedTitle = $title;
228 break;
229 }
230 }
231
232 if ( $params["{$prefix}id"] !== null ) {
233 $title = Title::newFromID( $params["{$prefix}id"] );
234 if ( $title ) {
235 $this->guessedTitle = $title;
236 break;
237 }
238 }
239 }
240
241 if ( !$this->guessedModel ) {
242 if ( $this->guessedTitle ) {
243 $this->guessedModel = $this->guessedTitle->getContentModel();
244 } elseif ( $params['fromcontentmodel'] !== null ) {
245 $this->guessedModel = $params['fromcontentmodel'];
246 } elseif ( $params['tocontentmodel'] !== null ) {
247 $this->guessedModel = $params['tocontentmodel'];
248 }
249 }
250 }
251
252 /**
253 * Get the Revision and Content for one side of the diff
254 *
255 * This uses the appropriate set of 'rev', 'id', 'title', 'text', 'pst',
256 * 'contentmodel', and 'contentformat' parameters to determine what content
257 * should be diffed.
258 *
259 * Returns three values:
260 * - The revision used to retrieve the content, if any
261 * - The content to be diffed
262 * - The revision specified, if any, even if not used to retrieve the
263 * Content
264 *
265 * @param string $prefix 'from' or 'to'
266 * @param array $params
267 * @return array [ Revision|null, Content, Revision|null ]
268 */
269 private function getDiffContent( $prefix, array $params ) {
270 $title = null;
271 $rev = null;
272 $suppliedContent = $params["{$prefix}text"] !== null;
273
274 // Get the revision and title, if applicable
275 $revId = null;
276 if ( $params["{$prefix}rev"] !== null ) {
277 $revId = $params["{$prefix}rev"];
278 } elseif ( $params["{$prefix}title"] !== null || $params["{$prefix}id"] !== null ) {
279 if ( $params["{$prefix}title"] !== null ) {
280 $title = Title::newFromText( $params["{$prefix}title"] );
281 if ( !$title || $title->isExternal() ) {
282 $this->dieWithError(
283 [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ]
284 );
285 }
286 } else {
287 $title = Title::newFromID( $params["{$prefix}id"] );
288 if ( !$title ) {
289 $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] );
290 }
291 }
292 $revId = $title->getLatestRevID();
293 if ( !$revId ) {
294 $revId = null;
295 // Only die here if we're not using supplied text
296 if ( !$suppliedContent ) {
297 if ( $title->exists() ) {
298 $this->dieWithError(
299 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
300 );
301 } else {
302 $this->dieWithError(
303 [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ],
304 'missingtitle'
305 );
306 }
307 }
308 }
309 }
310 if ( $revId !== null ) {
311 $rev = Revision::newFromId( $revId );
312 if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
313 // Try the 'archive' table
314 $arQuery = Revision::getArchiveQueryInfo();
315 $row = $this->getDB()->selectRow(
316 $arQuery['tables'],
317 array_merge(
318 $arQuery['fields'],
319 [ 'ar_namespace', 'ar_title' ]
320 ),
321 [ 'ar_rev_id' => $revId ],
322 __METHOD__,
323 [],
324 $arQuery['joins']
325 );
326 if ( $row ) {
327 $rev = Revision::newFromArchiveRow( $row );
328 $rev->isArchive = true;
329 }
330 }
331 if ( !$rev ) {
332 $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
333 }
334 $title = $rev->getTitle();
335
336 // If we don't have supplied content, return here. Otherwise,
337 // continue on below with the supplied content.
338 if ( !$suppliedContent ) {
339 $content = $rev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
340 if ( !$content ) {
341 $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ], 'missingcontent' );
342 }
343 return [ $rev, $content, $rev ];
344 }
345 }
346
347 // Override $content based on supplied text
348 $model = $params["{$prefix}contentmodel"];
349 $format = $params["{$prefix}contentformat"];
350
351 if ( !$model && $rev ) {
352 $model = $rev->getContentModel();
353 }
354 if ( !$model && $title ) {
355 $model = $title->getContentModel();
356 }
357 if ( !$model ) {
358 $this->guessTitleAndModel();
359 $model = $this->guessedModel;
360 }
361 if ( !$model ) {
362 $model = CONTENT_MODEL_WIKITEXT;
363 $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
364 }
365
366 if ( !$title ) {
367 $this->guessTitleAndModel();
368 $title = $this->guessedTitle;
369 }
370
371 try {
372 $content = ContentHandler::makeContent( $params["{$prefix}text"], $title, $model, $format );
373 } catch ( MWContentSerializationException $ex ) {
374 $this->dieWithException( $ex, [
375 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
376 ] );
377 }
378
379 if ( $params["{$prefix}pst"] ) {
380 if ( !$title ) {
381 $this->dieWithError( 'apierror-compare-no-title' );
382 }
383 $popts = ParserOptions::newFromContext( $this->getContext() );
384 $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
385 }
386
387 return [ null, $content, $rev ];
388 }
389
390 /**
391 * Set value fields from a Revision object
392 * @param array &$vals Result array to set data into
393 * @param string $prefix 'from' or 'to'
394 * @param Revision|null $rev
395 */
396 private function setVals( &$vals, $prefix, $rev ) {
397 if ( $rev ) {
398 $title = $rev->getTitle();
399 if ( isset( $this->props['ids'] ) ) {
400 $vals["{$prefix}id"] = $title->getArticleID();
401 $vals["{$prefix}revid"] = $rev->getId();
402 }
403 if ( isset( $this->props['title'] ) ) {
404 ApiQueryBase::addTitleInfo( $vals, $title, $prefix );
405 }
406 if ( isset( $this->props['size'] ) ) {
407 $vals["{$prefix}size"] = $rev->getSize();
408 }
409
410 $anyHidden = false;
411 if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
412 $vals["{$prefix}texthidden"] = true;
413 $anyHidden = true;
414 }
415
416 if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
417 $vals["{$prefix}userhidden"] = true;
418 $anyHidden = true;
419 }
420 if ( isset( $this->props['user'] ) &&
421 $rev->userCan( Revision::DELETED_USER, $this->getUser() )
422 ) {
423 $vals["{$prefix}user"] = $rev->getUserText( Revision::RAW );
424 $vals["{$prefix}userid"] = $rev->getUser( Revision::RAW );
425 }
426
427 if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) {
428 $vals["{$prefix}commenthidden"] = true;
429 $anyHidden = true;
430 }
431 if ( $rev->userCan( Revision::DELETED_COMMENT, $this->getUser() ) ) {
432 if ( isset( $this->props['comment'] ) ) {
433 $vals["{$prefix}comment"] = $rev->getComment( Revision::RAW );
434 }
435 if ( isset( $this->props['parsedcomment'] ) ) {
436 $vals["{$prefix}parsedcomment"] = Linker::formatComment(
437 $rev->getComment( Revision::RAW ),
438 $rev->getTitle()
439 );
440 }
441 }
442
443 if ( $anyHidden ) {
444 $this->getMain()->setCacheMode( 'private' );
445 if ( $rev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
446 $vals["{$prefix}suppressed"] = true;
447 }
448 }
449
450 if ( !empty( $rev->isArchive ) ) {
451 $this->getMain()->setCacheMode( 'private' );
452 $vals["{$prefix}archive"] = true;
453 }
454 }
455 }
456
457 public function getAllowedParams() {
458 // Parameters for the 'from' and 'to' content
459 $fromToParams = [
460 'title' => null,
461 'id' => [
462 ApiBase::PARAM_TYPE => 'integer'
463 ],
464 'rev' => [
465 ApiBase::PARAM_TYPE => 'integer'
466 ],
467 'text' => [
468 ApiBase::PARAM_TYPE => 'text'
469 ],
470 'section' => null,
471 'pst' => false,
472 'contentformat' => [
473 ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
474 ],
475 'contentmodel' => [
476 ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
477 ]
478 ];
479
480 $ret = [];
481 foreach ( $fromToParams as $k => $v ) {
482 $ret["from$k"] = $v;
483 }
484 foreach ( $fromToParams as $k => $v ) {
485 $ret["to$k"] = $v;
486 }
487
488 $ret = wfArrayInsertAfter(
489 $ret,
490 [ 'torelative' => [ ApiBase::PARAM_TYPE => [ 'prev', 'next', 'cur' ], ] ],
491 'torev'
492 );
493
494 $ret['prop'] = [
495 ApiBase::PARAM_DFLT => 'diff|ids|title',
496 ApiBase::PARAM_TYPE => [
497 'diff',
498 'diffsize',
499 'rel',
500 'ids',
501 'title',
502 'user',
503 'comment',
504 'parsedcomment',
505 'size',
506 ],
507 ApiBase::PARAM_ISMULTI => true,
508 ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
509 ];
510
511 return $ret;
512 }
513
514 protected function getExamplesMessages() {
515 return [
516 'action=compare&fromrev=1&torev=2'
517 => 'apihelp-compare-example-1',
518 ];
519 }
520 }