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.
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.
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
22 use MediaWiki\MediaWikiServices
;
23 use MediaWiki\Storage\MutableRevisionRecord
;
24 use MediaWiki\Storage\RevisionRecord
;
25 use MediaWiki\Storage\RevisionStore
;
27 class ApiComparePages
extends ApiBase
{
29 /** @var RevisionStore */
30 private $revisionStore;
32 private $guessedTitle = false, $props;
34 public function __construct( ApiMain
$mainModule, $moduleName, $modulePrefix = '' ) {
35 parent
::__construct( $mainModule, $moduleName, $modulePrefix );
36 $this->revisionStore
= MediaWikiServices
::getInstance()->getRevisionStore();
39 public function execute() {
40 $params = $this->extractRequestParams();
42 // Parameter validation
43 $this->requireAtLeastOneParameter(
44 $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext', 'fromslots'
46 $this->requireAtLeastOneParameter(
47 $params, 'totitle', 'toid', 'torev', 'totext', 'torelative', 'toslots'
50 $this->props
= array_flip( $params['prop'] );
52 // Cache responses publicly by default. This may be overridden later.
53 $this->getMain()->setCacheMode( 'public' );
55 // Get the 'from' RevisionRecord
56 list( $fromRev, $fromRelRev, $fromValsRev ) = $this->getDiffRevision( 'from', $params );
58 // Get the 'to' RevisionRecord
59 if ( $params['torelative'] !== null ) {
61 $this->dieWithError( 'apierror-compare-relative-to-nothing' );
63 switch ( $params['torelative'] ) {
65 // Swap 'from' and 'to'
66 list( $toRev, $toRelRev2, $toValsRev ) = [ $fromRev, $fromRelRev, $fromValsRev ];
67 $fromRev = $this->revisionStore
->getPreviousRevision( $fromRelRev );
68 $fromRelRev = $fromRev;
69 $fromValsRev = $fromRev;
73 $toRev = $this->revisionStore
->getNextRevision( $fromRelRev );
79 $title = $fromRelRev->getPageAsLinkTarget();
80 $toRev = $this->revisionStore
->getRevisionByTitle( $title );
82 $title = Title
::newFromLinkTarget( $title );
84 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
92 list( $toRev, $toRelRev, $toValsRev ) = $this->getDiffRevision( 'to', $params );
95 // Handle missing from or to revisions
96 if ( !$fromRev ||
!$toRev ) {
97 $this->dieWithError( 'apierror-baddiff' );
101 if ( !$fromRev->audienceCan(
102 RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_THIS_USER
, $this->getUser()
104 $this->dieWithError( [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' );
106 if ( !$toRev->audienceCan(
107 RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_THIS_USER
, $this->getUser()
109 $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
113 $context = new DerivativeContext( $this->getContext() );
114 if ( $fromRelRev && $fromRelRev->getPageAsLinkTarget() ) {
115 $context->setTitle( Title
::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() ) );
116 } elseif ( $toRelRev && $toRelRev->getPageAsLinkTarget() ) {
117 $context->setTitle( Title
::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() ) );
119 $guessedTitle = $this->guessTitle();
120 if ( $guessedTitle ) {
121 $context->setTitle( $guessedTitle );
124 $de = new DifferenceEngine( $context );
125 $de->setRevisions( $fromRev, $toRev );
126 if ( $params['slots'] === null ) {
127 $difftext = $de->getDiffBody();
128 if ( $difftext === false ) {
129 $this->dieWithError( 'apierror-baddiff' );
133 foreach ( $params['slots'] as $role ) {
134 $difftext[$role] = $de->getDiffBodyForRole( $role );
138 // Fill in the response
140 $this->setVals( $vals, 'from', $fromValsRev );
141 $this->setVals( $vals, 'to', $toValsRev );
143 if ( isset( $this->props
['rel'] ) ) {
144 if ( !$fromRev instanceof MutableRevisionRecord
) {
145 $rev = $this->revisionStore
->getPreviousRevision( $fromRev );
147 $vals['prev'] = $rev->getId();
150 if ( !$toRev instanceof MutableRevisionRecord
) {
151 $rev = $this->revisionStore
->getNextRevision( $toRev );
153 $vals['next'] = $rev->getId();
158 if ( isset( $this->props
['diffsize'] ) ) {
159 $vals['diffsize'] = 0;
160 foreach ( (array)$difftext as $text ) {
161 $vals['diffsize'] +
= strlen( $text );
164 if ( isset( $this->props
['diff'] ) ) {
165 if ( is_array( $difftext ) ) {
166 ApiResult
::setArrayType( $difftext, 'kvp', 'diff' );
167 $vals['bodies'] = $difftext;
169 ApiResult
::setContentValue( $vals, 'body', $difftext );
173 // Diffs can be really big and there's little point in having
174 // ApiResult truncate it to an empty response since the diff is the
175 // whole reason this module exists. So pass NO_SIZE_CHECK here.
176 $this->getResult()->addValue( null, $this->getModuleName(), $vals, ApiResult
::NO_SIZE_CHECK
);
180 * Load a revision by ID
182 * Falls back to checking the archive table if appropriate.
185 * @return RevisionRecord|null
187 private function getRevisionById( $id ) {
188 $rev = $this->revisionStore
->getRevisionById( $id );
189 if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
190 // Try the 'archive' table
191 $arQuery = $this->revisionStore
->getArchiveQueryInfo();
192 $row = $this->getDB()->selectRow(
196 [ 'ar_namespace', 'ar_title' ]
198 [ 'ar_rev_id' => $id ],
204 $rev = $this->revisionStore
->newRevisionFromArchiveRow( $row );
205 $rev->isArchive
= true;
212 * Guess an appropriate default Title for this request
216 private function guessTitle() {
217 if ( $this->guessedTitle
!== false ) {
218 return $this->guessedTitle
;
221 $this->guessedTitle
= null;
222 $params = $this->extractRequestParams();
224 foreach ( [ 'from', 'to' ] as $prefix ) {
225 if ( $params["{$prefix}rev"] !== null ) {
226 $rev = $this->getRevisionById( $params["{$prefix}rev"] );
228 $this->guessedTitle
= Title
::newFromLinkTarget( $rev->getPageAsLinkTarget() );
233 if ( $params["{$prefix}title"] !== null ) {
234 $title = Title
::newFromText( $params["{$prefix}title"] );
235 if ( $title && !$title->isExternal() ) {
236 $this->guessedTitle
= $title;
241 if ( $params["{$prefix}id"] !== null ) {
242 $title = Title
::newFromID( $params["{$prefix}id"] );
244 $this->guessedTitle
= $title;
250 return $this->guessedTitle
;
254 * Guess an appropriate default content model for this request
255 * @param string $role Slot for which to guess the model
256 * @return string|null Guessed content model
258 private function guessModel( $role ) {
259 $params = $this->extractRequestParams();
262 foreach ( [ 'from', 'to' ] as $prefix ) {
263 if ( $params["{$prefix}rev"] !== null ) {
264 $rev = $this->getRevisionById( $params["{$prefix}rev"] );
266 if ( $rev->hasSlot( $role ) ) {
267 return $rev->getSlot( $role, RevisionRecord
::RAW
)->getModel();
273 $guessedTitle = $this->guessTitle();
274 if ( $guessedTitle && $role === 'main' ) {
275 // @todo: Use SlotRoleRegistry and do this for all slots
276 return $guessedTitle->getContentModel();
279 if ( isset( $params["fromcontentmodel-$role"] ) ) {
280 return $params["fromcontentmodel-$role"];
282 if ( isset( $params["tocontentmodel-$role"] ) ) {
283 return $params["tocontentmodel-$role"];
286 if ( $role === 'main' ) {
287 if ( isset( $params['fromcontentmodel'] ) ) {
288 return $params['fromcontentmodel'];
290 if ( isset( $params['tocontentmodel'] ) ) {
291 return $params['tocontentmodel'];
299 * Get the RevisionRecord for one side of the diff
301 * This uses the appropriate set of parameters to determine what content
304 * Returns three values:
305 * - A RevisionRecord holding the content
306 * - The revision specified, if any, even if content was supplied
307 * - The revision to pass to setVals(), if any
309 * @param string $prefix 'from' or 'to'
310 * @param array $params
311 * @return array [ RevisionRecord|null, RevisionRecord|null, RevisionRecord|null ]
313 private function getDiffRevision( $prefix, array $params ) {
314 // Back compat params
315 $this->requireMaxOneParameter( $params, "{$prefix}text", "{$prefix}slots" );
316 $this->requireMaxOneParameter( $params, "{$prefix}section", "{$prefix}slots" );
317 if ( $params["{$prefix}text"] !== null ) {
318 $params["{$prefix}slots"] = [ 'main' ];
319 $params["{$prefix}text-main"] = $params["{$prefix}text"];
320 $params["{$prefix}section-main"] = null;
321 $params["{$prefix}contentmodel-main"] = $params["{$prefix}contentmodel"];
322 $params["{$prefix}contentformat-main"] = $params["{$prefix}contentformat"];
327 $suppliedContent = $params["{$prefix}slots"] !== null;
329 // Get the revision and title, if applicable
331 if ( $params["{$prefix}rev"] !== null ) {
332 $revId = $params["{$prefix}rev"];
333 } elseif ( $params["{$prefix}title"] !== null ||
$params["{$prefix}id"] !== null ) {
334 if ( $params["{$prefix}title"] !== null ) {
335 $title = Title
::newFromText( $params["{$prefix}title"] );
336 if ( !$title ||
$title->isExternal() ) {
338 [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ]
342 $title = Title
::newFromID( $params["{$prefix}id"] );
344 $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] );
347 $revId = $title->getLatestRevID();
350 // Only die here if we're not using supplied text
351 if ( !$suppliedContent ) {
352 if ( $title->exists() ) {
354 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
358 [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ],
365 if ( $revId !== null ) {
366 $rev = $this->getRevisionById( $revId );
368 $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
370 $title = Title
::newFromLinkTarget( $rev->getPageAsLinkTarget() );
372 // If we don't have supplied content, return here. Otherwise,
373 // continue on below with the supplied content.
374 if ( !$suppliedContent ) {
377 // Deprecated 'fromsection'/'tosection'
378 if ( isset( $params["{$prefix}section"] ) ) {
379 $section = $params["{$prefix}section"];
380 $newRev = MutableRevisionRecord
::newFromParentRevision( $rev );
381 $content = $rev->getContent( 'main', RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
384 [ 'apierror-missingcontent-revid-role', $rev->getId(), 'main' ], 'missingcontent'
387 $content = $content ?
$content->getSection( $section ) : null;
390 [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
391 "nosuch{$prefix}section"
394 $newRev->setContent( 'main', $content );
397 return [ $newRev, $rev, $rev ];
401 // Override $content based on supplied text
403 $title = $this->guessTitle();
406 $newRev = MutableRevisionRecord
::newFromParentRevision( $rev );
408 $newRev = $this->revisionStore
->newMutableRevisionFromArray( [
409 'title' => $title ?
: Title
::makeTitle( NS_SPECIAL
, 'Badtitle/' . __METHOD__
)
412 foreach ( $params["{$prefix}slots"] as $role ) {
413 $text = $params["{$prefix}text-{$role}"];
414 if ( $text === null ) {
415 // The 'main' role can't be deleted
416 if ( $role === 'main' ) {
417 $this->dieWithError( [ 'apierror-compare-maintextrequired', $prefix ] );
420 // These parameters make no sense without text. Reject them to avoid
422 foreach ( [ 'section', 'contentmodel', 'contentformat' ] as $param ) {
423 if ( isset( $params["{$prefix}{$param}-{$role}"] ) ) {
424 $this->dieWithError( [
425 'apierror-compare-notext',
426 wfEscapeWikiText( "{$prefix}{$param}-{$role}" ),
427 wfEscapeWikiText( "{$prefix}text-{$role}" ),
432 $newRev->removeSlot( $role );
436 $model = $params["{$prefix}contentmodel-{$role}"];
437 $format = $params["{$prefix}contentformat-{$role}"];
439 if ( !$model && $rev && $rev->hasSlot( $role ) ) {
440 $model = $rev->getSlot( $role, RevisionRecord
::RAW
)->getModel();
442 if ( !$model && $title && $role === 'main' ) {
443 // @todo: Use SlotRoleRegistry and do this for all slots
444 $model = $title->getContentModel();
447 $model = $this->guessModel( $role );
450 $model = CONTENT_MODEL_WIKITEXT
;
451 $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
455 $content = ContentHandler
::makeContent( $text, $title, $model, $format );
456 } catch ( MWContentSerializationException
$ex ) {
457 $this->dieWithException( $ex, [
458 'wrap' => ApiMessage
::create( 'apierror-contentserializationexception', 'parseerror' )
462 if ( $params["{$prefix}pst"] ) {
464 $this->dieWithError( 'apierror-compare-no-title' );
466 $popts = ParserOptions
::newFromContext( $this->getContext() );
467 $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
470 $section = $params["{$prefix}section-{$role}"];
471 if ( $section !== null && $section !== '' ) {
473 $this->dieWithError( "apierror-compare-no{$prefix}revision" );
475 $oldContent = $rev->getContent( $role, RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
476 if ( !$oldContent ) {
478 [ 'apierror-missingcontent-revid-role', $rev->getId(), wfEscapeWikiText( $role ) ],
482 if ( !$oldContent->getContentHandler()->supportsSections() ) {
483 $this->dieWithError( [ 'apierror-sectionsnotsupported', $content->getModel() ] );
486 $content = $oldContent->replaceSection( $section, $content, '' );
487 } catch ( Exception
$ex ) {
488 // Probably a content model mismatch.
492 $this->dieWithError( [ 'apierror-sectionreplacefailed' ] );
496 // Deprecated 'fromsection'/'tosection'
497 if ( $role === 'main' && isset( $params["{$prefix}section"] ) ) {
498 $section = $params["{$prefix}section"];
499 $content = $content->getSection( $section );
502 [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
503 "nosuch{$prefix}section"
508 $newRev->setContent( $role, $content );
510 return [ $newRev, $rev, null ];
514 * Set value fields from a RevisionRecord object
516 * @param array &$vals Result array to set data into
517 * @param string $prefix 'from' or 'to'
518 * @param RevisionRecord|null $rev
520 private function setVals( &$vals, $prefix, $rev ) {
522 $title = $rev->getPageAsLinkTarget();
523 if ( isset( $this->props
['ids'] ) ) {
524 $vals["{$prefix}id"] = $title->getArticleID();
525 $vals["{$prefix}revid"] = $rev->getId();
527 if ( isset( $this->props
['title'] ) ) {
528 ApiQueryBase
::addTitleInfo( $vals, $title, $prefix );
530 if ( isset( $this->props
['size'] ) ) {
531 $vals["{$prefix}size"] = $rev->getSize();
535 if ( $rev->isDeleted( RevisionRecord
::DELETED_TEXT
) ) {
536 $vals["{$prefix}texthidden"] = true;
540 if ( $rev->isDeleted( RevisionRecord
::DELETED_USER
) ) {
541 $vals["{$prefix}userhidden"] = true;
544 if ( isset( $this->props
['user'] ) ) {
545 $user = $rev->getUser( RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
547 $vals["{$prefix}user"] = $user->getName();
548 $vals["{$prefix}userid"] = $user->getId();
552 if ( $rev->isDeleted( RevisionRecord
::DELETED_COMMENT
) ) {
553 $vals["{$prefix}commenthidden"] = true;
556 if ( isset( $this->props
['comment'] ) ||
isset( $this->props
['parsedcomment'] ) ) {
557 $comment = $rev->getComment( RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
558 if ( $comment !== null ) {
559 if ( isset( $this->props
['comment'] ) ) {
560 $vals["{$prefix}comment"] = $comment->text
;
562 $vals["{$prefix}parsedcomment"] = Linker
::formatComment(
563 $comment->text
, Title
::newFromLinkTarget( $title )
569 $this->getMain()->setCacheMode( 'private' );
570 if ( $rev->isDeleted( RevisionRecord
::DELETED_RESTRICTED
) ) {
571 $vals["{$prefix}suppressed"] = true;
575 if ( !empty( $rev->isArchive
) ) {
576 $this->getMain()->setCacheMode( 'private' );
577 $vals["{$prefix}archive"] = true;
582 public function getAllowedParams() {
583 $slotRoles = MediaWikiServices
::getInstance()->getSlotRoleStore()->getMap();
584 if ( !in_array( 'main', $slotRoles, true ) ) {
585 $slotRoles[] = 'main';
587 sort( $slotRoles, SORT_STRING
);
589 // Parameters for the 'from' and 'to' content
593 ApiBase
::PARAM_TYPE
=> 'integer'
596 ApiBase
::PARAM_TYPE
=> 'integer'
600 ApiBase
::PARAM_TYPE
=> $slotRoles,
601 ApiBase
::PARAM_ISMULTI
=> true,
604 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
605 ApiBase
::PARAM_TYPE
=> 'text',
607 'section-{slot}' => [
608 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
609 ApiBase
::PARAM_TYPE
=> 'string',
611 'contentformat-{slot}' => [
612 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
613 ApiBase
::PARAM_TYPE
=> ContentHandler
::getAllContentFormats(),
615 'contentmodel-{slot}' => [
616 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
617 ApiBase
::PARAM_TYPE
=> ContentHandler
::getContentModels(),
622 ApiBase
::PARAM_TYPE
=> 'text',
623 ApiBase
::PARAM_DEPRECATED
=> true,
626 ApiBase
::PARAM_TYPE
=> ContentHandler
::getAllContentFormats(),
627 ApiBase
::PARAM_DEPRECATED
=> true,
630 ApiBase
::PARAM_TYPE
=> ContentHandler
::getContentModels(),
631 ApiBase
::PARAM_DEPRECATED
=> true,
634 ApiBase
::PARAM_DFLT
=> null,
635 ApiBase
::PARAM_DEPRECATED
=> true,
640 foreach ( $fromToParams as $k => $v ) {
641 if ( isset( $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] ) ) {
642 $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] = 'fromslots';
646 foreach ( $fromToParams as $k => $v ) {
647 if ( isset( $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] ) ) {
648 $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] = 'toslots';
653 $ret = wfArrayInsertAfter(
655 [ 'torelative' => [ ApiBase
::PARAM_TYPE
=> [ 'prev', 'next', 'cur' ], ] ],
660 ApiBase
::PARAM_DFLT
=> 'diff|ids|title',
661 ApiBase
::PARAM_TYPE
=> [
672 ApiBase
::PARAM_ISMULTI
=> true,
673 ApiBase
::PARAM_HELP_MSG_PER_VALUE
=> [],
677 ApiBase
::PARAM_TYPE
=> $slotRoles,
678 ApiBase
::PARAM_ISMULTI
=> true,
679 ApiBase
::PARAM_ALL
=> true,
685 protected function getExamplesMessages() {
687 'action=compare&fromrev=1&torev=2'
688 => 'apihelp-compare-example-1',