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\Revision\MutableRevisionRecord
;
24 use MediaWiki\Revision\RevisionRecord
;
25 use MediaWiki\Revision\RevisionArchiveRecord
;
26 use MediaWiki\Revision\RevisionStore
;
27 use MediaWiki\Revision\SlotRecord
;
32 class ApiComparePages
extends ApiBase
{
34 /** @var RevisionStore */
35 private $revisionStore;
37 /** @var \MediaWiki\Revision\SlotRoleRegistry */
38 private $slotRoleRegistry;
40 private $guessedTitle = false, $props;
42 public function __construct( ApiMain
$mainModule, $moduleName, $modulePrefix = '' ) {
43 parent
::__construct( $mainModule, $moduleName, $modulePrefix );
44 $this->revisionStore
= MediaWikiServices
::getInstance()->getRevisionStore();
45 $this->slotRoleRegistry
= MediaWikiServices
::getInstance()->getSlotRoleRegistry();
48 public function execute() {
49 $params = $this->extractRequestParams();
51 // Parameter validation
52 $this->requireAtLeastOneParameter(
53 $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext', 'fromslots'
55 $this->requireAtLeastOneParameter(
56 $params, 'totitle', 'toid', 'torev', 'totext', 'torelative', 'toslots'
59 $this->props
= array_flip( $params['prop'] );
61 // Cache responses publicly by default. This may be overridden later.
62 $this->getMain()->setCacheMode( 'public' );
64 // Get the 'from' RevisionRecord
65 list( $fromRev, $fromRelRev, $fromValsRev ) = $this->getDiffRevision( 'from', $params );
67 // Get the 'to' RevisionRecord
68 if ( $params['torelative'] !== null ) {
70 $this->dieWithError( 'apierror-compare-relative-to-nothing' );
72 if ( $params['torelative'] !== 'cur' && $fromRelRev instanceof RevisionArchiveRecord
) {
73 // RevisionStore's getPreviousRevision/getNextRevision blow up
74 // when passed an RevisionArchiveRecord for a deleted page
75 $this->dieWithError( [ 'apierror-compare-relative-to-deleted', $params['torelative'] ] );
77 switch ( $params['torelative'] ) {
79 // Swap 'from' and 'to'
80 list( $toRev, $toRelRev, $toValsRev ) = [ $fromRev, $fromRelRev, $fromValsRev ];
81 $fromRev = $this->revisionStore
->getPreviousRevision( $toRelRev );
82 $fromRelRev = $fromRev;
83 $fromValsRev = $fromRev;
85 $title = Title
::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() );
87 'apiwarn-compare-no-prev',
88 wfEscapeWikiText( $title->getPrefixedText() ),
92 // (T203433) Create an empty dummy revision as the "previous".
93 // The main slot has to exist, the rest will be handled by DifferenceEngine.
94 $fromRev = $this->revisionStore
->newMutableRevisionFromArray( [
95 'title' => $title ?
: Title
::makeTitle( NS_SPECIAL
, 'Badtitle/' . __METHOD__
)
99 $toRelRev->getContent( SlotRecord
::MAIN
, RevisionRecord
::RAW
)
100 ->getContentHandler()
107 $toRev = $this->revisionStore
->getNextRevision( $fromRelRev );
111 $title = Title
::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() );
113 'apiwarn-compare-no-next',
114 wfEscapeWikiText( $title->getPrefixedText() ),
118 // (T203433) The web UI treats "next" as "cur" in this case.
119 // Avoid repeating metadata by making a MutableRevisionRecord with no changes.
120 $toRev = MutableRevisionRecord
::newFromParentRevision( $fromRelRev );
125 $title = $fromRelRev->getPageAsLinkTarget();
126 $toRev = $this->revisionStore
->getRevisionByTitle( $title );
128 $title = Title
::newFromLinkTarget( $title );
130 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
138 list( $toRev, $toRelRev, $toValsRev ) = $this->getDiffRevision( 'to', $params );
141 // Handle missing from or to revisions (should never happen)
142 // @codeCoverageIgnoreStart
143 if ( !$fromRev ||
!$toRev ) {
144 $this->dieWithError( 'apierror-baddiff' );
146 // @codeCoverageIgnoreEnd
149 if ( !$fromRev->audienceCan(
150 RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_THIS_USER
, $this->getUser()
152 $this->dieWithError( [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' );
154 if ( !$toRev->audienceCan(
155 RevisionRecord
::DELETED_TEXT
, RevisionRecord
::FOR_THIS_USER
, $this->getUser()
157 $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
161 $context = new DerivativeContext( $this->getContext() );
162 if ( $fromRelRev && $fromRelRev->getPageAsLinkTarget() ) {
163 $context->setTitle( Title
::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() ) );
164 } elseif ( $toRelRev && $toRelRev->getPageAsLinkTarget() ) {
165 $context->setTitle( Title
::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() ) );
167 $guessedTitle = $this->guessTitle();
168 if ( $guessedTitle ) {
169 $context->setTitle( $guessedTitle );
172 $de = new DifferenceEngine( $context );
173 $de->setRevisions( $fromRev, $toRev );
174 if ( $params['slots'] === null ) {
175 $difftext = $de->getDiffBody();
176 if ( $difftext === false ) {
177 $this->dieWithError( 'apierror-baddiff' );
181 foreach ( $params['slots'] as $role ) {
182 $difftext[$role] = $de->getDiffBodyForRole( $role );
186 // Fill in the response
188 $this->setVals( $vals, 'from', $fromValsRev );
189 $this->setVals( $vals, 'to', $toValsRev );
191 if ( isset( $this->props
['rel'] ) ) {
192 if ( !$fromRev instanceof MutableRevisionRecord
) {
193 $rev = $this->revisionStore
->getPreviousRevision( $fromRev );
195 $vals['prev'] = $rev->getId();
198 if ( !$toRev instanceof MutableRevisionRecord
) {
199 $rev = $this->revisionStore
->getNextRevision( $toRev );
201 $vals['next'] = $rev->getId();
206 if ( isset( $this->props
['diffsize'] ) ) {
207 $vals['diffsize'] = 0;
208 foreach ( (array)$difftext as $text ) {
209 $vals['diffsize'] +
= strlen( $text );
212 if ( isset( $this->props
['diff'] ) ) {
213 if ( is_array( $difftext ) ) {
214 ApiResult
::setArrayType( $difftext, 'kvp', 'diff' );
215 $vals['bodies'] = $difftext;
217 ApiResult
::setContentValue( $vals, 'body', $difftext );
221 // Diffs can be really big and there's little point in having
222 // ApiResult truncate it to an empty response since the diff is the
223 // whole reason this module exists. So pass NO_SIZE_CHECK here.
224 $this->getResult()->addValue( null, $this->getModuleName(), $vals, ApiResult
::NO_SIZE_CHECK
);
228 * Load a revision by ID
230 * Falls back to checking the archive table if appropriate.
233 * @return RevisionRecord|null
235 private function getRevisionById( $id ) {
236 $rev = $this->revisionStore
->getRevisionById( $id );
237 if ( !$rev && $this->getPermissionManager()
238 ->userHasAnyRight( $this->getUser(), 'deletedtext', 'undelete' )
240 // Try the 'archive' table
241 $arQuery = $this->revisionStore
->getArchiveQueryInfo();
242 $row = $this->getDB()->selectRow(
246 [ 'ar_namespace', 'ar_title' ]
248 [ 'ar_rev_id' => $id ],
254 $rev = $this->revisionStore
->newRevisionFromArchiveRow( $row );
255 $rev->isArchive
= true;
262 * Guess an appropriate default Title for this request
266 private function guessTitle() {
267 if ( $this->guessedTitle
!== false ) {
268 return $this->guessedTitle
;
271 $this->guessedTitle
= null;
272 $params = $this->extractRequestParams();
274 foreach ( [ 'from', 'to' ] as $prefix ) {
275 if ( $params["{$prefix}rev"] !== null ) {
276 $rev = $this->getRevisionById( $params["{$prefix}rev"] );
278 $this->guessedTitle
= Title
::newFromLinkTarget( $rev->getPageAsLinkTarget() );
283 if ( $params["{$prefix}title"] !== null ) {
284 $title = Title
::newFromText( $params["{$prefix}title"] );
285 if ( $title && !$title->isExternal() ) {
286 $this->guessedTitle
= $title;
291 if ( $params["{$prefix}id"] !== null ) {
292 $title = Title
::newFromID( $params["{$prefix}id"] );
294 $this->guessedTitle
= $title;
300 return $this->guessedTitle
;
304 * Guess an appropriate default content model for this request
305 * @param string $role Slot for which to guess the model
306 * @return string|null Guessed content model
308 private function guessModel( $role ) {
309 $params = $this->extractRequestParams();
312 foreach ( [ 'from', 'to' ] as $prefix ) {
313 if ( $params["{$prefix}rev"] !== null ) {
314 $rev = $this->getRevisionById( $params["{$prefix}rev"] );
315 if ( $rev && $rev->hasSlot( $role ) ) {
316 return $rev->getSlot( $role, RevisionRecord
::RAW
)->getModel();
321 $guessedTitle = $this->guessTitle();
322 if ( $guessedTitle ) {
323 return $this->slotRoleRegistry
->getRoleHandler( $role )->getDefaultModel( $guessedTitle );
326 if ( isset( $params["fromcontentmodel-$role"] ) ) {
327 return $params["fromcontentmodel-$role"];
329 if ( isset( $params["tocontentmodel-$role"] ) ) {
330 return $params["tocontentmodel-$role"];
333 if ( $role === SlotRecord
::MAIN
) {
334 if ( isset( $params['fromcontentmodel'] ) ) {
335 return $params['fromcontentmodel'];
337 if ( isset( $params['tocontentmodel'] ) ) {
338 return $params['tocontentmodel'];
346 * Get the RevisionRecord for one side of the diff
348 * This uses the appropriate set of parameters to determine what content
351 * Returns three values:
352 * - A RevisionRecord holding the content
353 * - The revision specified, if any, even if content was supplied
354 * - The revision to pass to setVals(), if any
356 * @param string $prefix 'from' or 'to'
357 * @param array $params
358 * @return array [ RevisionRecord|null, RevisionRecord|null, RevisionRecord|null ]
360 private function getDiffRevision( $prefix, array $params ) {
361 // Back compat params
362 $this->requireMaxOneParameter( $params, "{$prefix}text", "{$prefix}slots" );
363 $this->requireMaxOneParameter( $params, "{$prefix}section", "{$prefix}slots" );
364 if ( $params["{$prefix}text"] !== null ) {
365 $params["{$prefix}slots"] = [ SlotRecord
::MAIN
];
366 $params["{$prefix}text-main"] = $params["{$prefix}text"];
367 $params["{$prefix}section-main"] = null;
368 $params["{$prefix}contentmodel-main"] = $params["{$prefix}contentmodel"];
369 $params["{$prefix}contentformat-main"] = $params["{$prefix}contentformat"];
374 $suppliedContent = $params["{$prefix}slots"] !== null;
376 // Get the revision and title, if applicable
378 if ( $params["{$prefix}rev"] !== null ) {
379 $revId = $params["{$prefix}rev"];
380 } elseif ( $params["{$prefix}title"] !== null ||
$params["{$prefix}id"] !== null ) {
381 if ( $params["{$prefix}title"] !== null ) {
382 $title = Title
::newFromText( $params["{$prefix}title"] );
383 if ( !$title ||
$title->isExternal() ) {
385 [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ]
389 $title = Title
::newFromID( $params["{$prefix}id"] );
391 $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] );
394 $revId = $title->getLatestRevID();
397 // Only die here if we're not using supplied text
398 if ( !$suppliedContent ) {
399 if ( $title->exists() ) {
401 [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
405 [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ],
412 if ( $revId !== null ) {
413 $rev = $this->getRevisionById( $revId );
415 $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
417 $title = Title
::newFromLinkTarget( $rev->getPageAsLinkTarget() );
419 // If we don't have supplied content, return here. Otherwise,
420 // continue on below with the supplied content.
421 if ( !$suppliedContent ) {
424 // Deprecated 'fromsection'/'tosection'
425 if ( isset( $params["{$prefix}section"] ) ) {
426 $section = $params["{$prefix}section"];
427 $newRev = MutableRevisionRecord
::newFromParentRevision( $rev );
428 $content = $rev->getContent( SlotRecord
::MAIN
, RevisionRecord
::FOR_THIS_USER
,
432 [ 'apierror-missingcontent-revid-role', $rev->getId(), SlotRecord
::MAIN
], 'missingcontent'
435 $content = $content ?
$content->getSection( $section ) : null;
438 [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
439 "nosuch{$prefix}section"
442 $newRev->setContent( SlotRecord
::MAIN
, $content );
445 return [ $newRev, $rev, $rev ];
449 // Override $content based on supplied text
451 $title = $this->guessTitle();
454 $newRev = MutableRevisionRecord
::newFromParentRevision( $rev );
456 $newRev = $this->revisionStore
->newMutableRevisionFromArray( [
457 'title' => $title ?
: Title
::makeTitle( NS_SPECIAL
, 'Badtitle/' . __METHOD__
)
460 foreach ( $params["{$prefix}slots"] as $role ) {
461 $text = $params["{$prefix}text-{$role}"];
462 if ( $text === null ) {
463 // The SlotRecord::MAIN role can't be deleted
464 if ( $role === SlotRecord
::MAIN
) {
465 $this->dieWithError( [ 'apierror-compare-maintextrequired', $prefix ] );
468 // These parameters make no sense without text. Reject them to avoid
470 foreach ( [ 'section', 'contentmodel', 'contentformat' ] as $param ) {
471 if ( isset( $params["{$prefix}{$param}-{$role}"] ) ) {
472 $this->dieWithError( [
473 'apierror-compare-notext',
474 wfEscapeWikiText( "{$prefix}{$param}-{$role}" ),
475 wfEscapeWikiText( "{$prefix}text-{$role}" ),
480 $newRev->removeSlot( $role );
484 $model = $params["{$prefix}contentmodel-{$role}"];
485 $format = $params["{$prefix}contentformat-{$role}"];
487 if ( !$model && $rev && $rev->hasSlot( $role ) ) {
488 $model = $rev->getSlot( $role, RevisionRecord
::RAW
)->getModel();
490 if ( !$model && $title && $role === SlotRecord
::MAIN
) {
491 // @todo: Use SlotRoleRegistry and do this for all slots
492 $model = $title->getContentModel();
495 $model = $this->guessModel( $role );
498 $model = CONTENT_MODEL_WIKITEXT
;
499 $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
503 $content = ContentHandler
::makeContent( $text, $title, $model, $format );
504 } catch ( MWContentSerializationException
$ex ) {
505 $this->dieWithException( $ex, [
506 'wrap' => ApiMessage
::create( 'apierror-contentserializationexception', 'parseerror' )
510 if ( $params["{$prefix}pst"] ) {
512 $this->dieWithError( 'apierror-compare-no-title' );
514 $popts = ParserOptions
::newFromContext( $this->getContext() );
515 $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
518 $section = $params["{$prefix}section-{$role}"];
519 if ( $section !== null && $section !== '' ) {
521 $this->dieWithError( "apierror-compare-no{$prefix}revision" );
523 $oldContent = $rev->getContent( $role, RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
524 if ( !$oldContent ) {
526 [ 'apierror-missingcontent-revid-role', $rev->getId(), wfEscapeWikiText( $role ) ],
530 if ( !$oldContent->getContentHandler()->supportsSections() ) {
531 $this->dieWithError( [ 'apierror-sectionsnotsupported', $content->getModel() ] );
534 $content = $oldContent->replaceSection( $section, $content, '' );
535 } catch ( Exception
$ex ) {
536 // Probably a content model mismatch.
540 $this->dieWithError( [ 'apierror-sectionreplacefailed' ] );
544 // Deprecated 'fromsection'/'tosection'
545 if ( $role === SlotRecord
::MAIN
&& isset( $params["{$prefix}section"] ) ) {
546 $section = $params["{$prefix}section"];
547 $content = $content->getSection( $section );
550 [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
551 "nosuch{$prefix}section"
556 $newRev->setContent( $role, $content );
558 return [ $newRev, $rev, null ];
562 * Set value fields from a RevisionRecord object
564 * @param array &$vals Result array to set data into
565 * @param string $prefix 'from' or 'to'
566 * @param RevisionRecord|null $rev
568 private function setVals( &$vals, $prefix, $rev ) {
570 $title = Title
::newFromLinkTarget( $rev->getPageAsLinkTarget() );
571 if ( isset( $this->props
['ids'] ) ) {
572 $vals["{$prefix}id"] = $title->getArticleID();
573 $vals["{$prefix}revid"] = $rev->getId();
575 if ( isset( $this->props
['title'] ) ) {
576 ApiQueryBase
::addTitleInfo( $vals, $title, $prefix );
578 if ( isset( $this->props
['size'] ) ) {
579 $vals["{$prefix}size"] = $rev->getSize();
583 if ( $rev->isDeleted( RevisionRecord
::DELETED_TEXT
) ) {
584 $vals["{$prefix}texthidden"] = true;
588 if ( $rev->isDeleted( RevisionRecord
::DELETED_USER
) ) {
589 $vals["{$prefix}userhidden"] = true;
592 if ( isset( $this->props
['user'] ) ) {
593 $user = $rev->getUser( RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
595 $vals["{$prefix}user"] = $user->getName();
596 $vals["{$prefix}userid"] = $user->getId();
600 if ( $rev->isDeleted( RevisionRecord
::DELETED_COMMENT
) ) {
601 $vals["{$prefix}commenthidden"] = true;
604 if ( isset( $this->props
['comment'] ) ||
isset( $this->props
['parsedcomment'] ) ) {
605 $comment = $rev->getComment( RevisionRecord
::FOR_THIS_USER
, $this->getUser() );
606 if ( $comment !== null ) {
607 if ( isset( $this->props
['comment'] ) ) {
608 $vals["{$prefix}comment"] = $comment->text
;
610 $vals["{$prefix}parsedcomment"] = Linker
::formatComment(
611 $comment->text
, $title
617 $this->getMain()->setCacheMode( 'private' );
618 if ( $rev->isDeleted( RevisionRecord
::DELETED_RESTRICTED
) ) {
619 $vals["{$prefix}suppressed"] = true;
623 if ( !empty( $rev->isArchive
) ) {
624 $this->getMain()->setCacheMode( 'private' );
625 $vals["{$prefix}archive"] = true;
630 public function getAllowedParams() {
631 $slotRoles = $this->slotRoleRegistry
->getKnownRoles();
632 sort( $slotRoles, SORT_STRING
);
634 // Parameters for the 'from' and 'to' content
638 ApiBase
::PARAM_TYPE
=> 'integer'
641 ApiBase
::PARAM_TYPE
=> 'integer'
645 ApiBase
::PARAM_TYPE
=> $slotRoles,
646 ApiBase
::PARAM_ISMULTI
=> true,
649 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
650 ApiBase
::PARAM_TYPE
=> 'text',
652 'section-{slot}' => [
653 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
654 ApiBase
::PARAM_TYPE
=> 'string',
656 'contentformat-{slot}' => [
657 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
658 ApiBase
::PARAM_TYPE
=> ContentHandler
::getAllContentFormats(),
660 'contentmodel-{slot}' => [
661 ApiBase
::PARAM_TEMPLATE_VARS
=> [ 'slot' => 'slots' ], // fixed below
662 ApiBase
::PARAM_TYPE
=> ContentHandler
::getContentModels(),
667 ApiBase
::PARAM_TYPE
=> 'text',
668 ApiBase
::PARAM_DEPRECATED
=> true,
671 ApiBase
::PARAM_TYPE
=> ContentHandler
::getAllContentFormats(),
672 ApiBase
::PARAM_DEPRECATED
=> true,
675 ApiBase
::PARAM_TYPE
=> ContentHandler
::getContentModels(),
676 ApiBase
::PARAM_DEPRECATED
=> true,
679 ApiBase
::PARAM_DFLT
=> null,
680 ApiBase
::PARAM_DEPRECATED
=> true,
685 foreach ( $fromToParams as $k => $v ) {
686 if ( isset( $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] ) ) {
687 $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] = 'fromslots';
691 foreach ( $fromToParams as $k => $v ) {
692 if ( isset( $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] ) ) {
693 $v[ApiBase
::PARAM_TEMPLATE_VARS
]['slot'] = 'toslots';
698 $ret = wfArrayInsertAfter(
700 [ 'torelative' => [ ApiBase
::PARAM_TYPE
=> [ 'prev', 'next', 'cur' ], ] ],
705 ApiBase
::PARAM_DFLT
=> 'diff|ids|title',
706 ApiBase
::PARAM_TYPE
=> [
717 ApiBase
::PARAM_ISMULTI
=> true,
718 ApiBase
::PARAM_HELP_MSG_PER_VALUE
=> [],
722 ApiBase
::PARAM_TYPE
=> $slotRoles,
723 ApiBase
::PARAM_ISMULTI
=> true,
724 ApiBase
::PARAM_ALL
=> true,
730 protected function getExamplesMessages() {
732 'action=compare&fromrev=1&torev=2'
733 => 'apihelp-compare-example-1',