From 1ab2f7a56ba5d1e4b613107d3e2991bbc36ba68e Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Thu, 26 Jul 2018 17:24:59 -0400 Subject: [PATCH] ApiComparePages: Update for MCR The main external change here is that it can now return diffs per slot, and the various parameters for providing text are deprecated in favor of templated per-slot versions. Also, this deprecates the 'fromsection' and 'tosection' behavior introduced for T183823 (extracting a section's content for the diff) in favor of the more logical behavior requested in T185723 (expanding 'fromtext-{slot}'/'totext-{slot}' as if for a section edit). Bug: T200569 Bug: T183823 Bug: T185723 Change-Id: I700edfa766bbc320887f2e0b7507fcdb11e72cdc --- RELEASE-NOTES-1.32 | 12 + includes/api/ApiComparePages.php | 576 +++++++++++------- includes/api/i18n/en.json | 32 +- includes/api/i18n/qqq.json | 36 +- includes/diff/DifferenceEngine.php | 28 + .../includes/api/ApiComparePagesTest.php | 351 ++++++++++- 6 files changed, 774 insertions(+), 261 deletions(-) diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 0ad2e41b65..a7fc232e4d 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -136,6 +136,18 @@ production. * action=query&prop=deletedrevisions, action=query&list=allrevisions, and action=query&list=alldeletedrevisions are changed similarly to &prop=revisions (see the three previous items). +* (T174032) action=compare now supports multi-content revisions. + * It has a 'slots' parameter to select diffing of individual slots. The + default behavior is to return one combined diff. + * The 'fromtext', 'fromsection', 'fromcontentmodel', 'fromcontentformat', + 'totext', 'tosection', 'tocontentmodel', and 'tocontentformat' parameters + are deprecated. Specify the new 'fromslots' and 'toslots' to identify which + slots have text supplied and the corresponding templated parameters for + each slot. + * The behavior of 'fromsection' and 'tosection' of extracting one section's + content is not being preserved. 'fromsection-{slot}' and 'tosection-{slot}' + instead expand the given text as if for a section edit. This effectively + declines T183823 in favor of T185723. === Action API internal changes in 1.32 === * Added 'ApiParseMakeOutputPage' hook. diff --git a/includes/api/ApiComparePages.php b/includes/api/ApiComparePages.php index 93c35d3d23..6bfa35dd6e 100644 --- a/includes/api/ApiComparePages.php +++ b/includes/api/ApiComparePages.php @@ -19,141 +19,136 @@ * @file */ +use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\MutableRevisionRecord; +use MediaWiki\Storage\RevisionRecord; +use MediaWiki\Storage\RevisionStore; + class ApiComparePages extends ApiBase { - private $guessed = false, $guessedTitle, $guessedModel, $props; + /** @var RevisionStore */ + private $revisionStore; + + private $guessedTitle = false, $props; + + public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) { + parent::__construct( $mainModule, $moduleName, $modulePrefix ); + $this->revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); + } public function execute() { $params = $this->extractRequestParams(); // Parameter validation - $this->requireAtLeastOneParameter( $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext' ); - $this->requireAtLeastOneParameter( $params, 'totitle', 'toid', 'torev', 'totext', 'torelative' ); + $this->requireAtLeastOneParameter( + $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext', 'fromslots' + ); + $this->requireAtLeastOneParameter( + $params, 'totitle', 'toid', 'torev', 'totext', 'torelative', 'toslots' + ); $this->props = array_flip( $params['prop'] ); // Cache responses publicly by default. This may be overridden later. $this->getMain()->setCacheMode( 'public' ); - // Get the 'from' Revision and Content - list( $fromRev, $fromContent, $relRev ) = $this->getDiffContent( 'from', $params ); + // Get the 'from' RevisionRecord + list( $fromRev, $fromRelRev, $fromValsRev ) = $this->getDiffRevision( 'from', $params ); - // Get the 'to' Revision and Content + // Get the 'to' RevisionRecord if ( $params['torelative'] !== null ) { - if ( !$relRev ) { + if ( !$fromRelRev ) { $this->dieWithError( 'apierror-compare-relative-to-nothing' ); } switch ( $params['torelative'] ) { case 'prev': // Swap 'from' and 'to' - $toRev = $fromRev; - $toContent = $fromContent; - $fromRev = $relRev->getPrevious(); - $fromContent = $fromRev - ? $fromRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ) - : $toContent->getContentHandler()->makeEmptyContent(); - if ( !$fromContent ) { - $this->dieWithError( - [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' - ); - } + list( $toRev, $toRelRev2, $toValsRev ) = [ $fromRev, $fromRelRev, $fromValsRev ]; + $fromRev = $this->revisionStore->getPreviousRevision( $fromRelRev ); + $fromRelRev = $fromRev; + $fromValsRev = $fromRev; break; case 'next': - $toRev = $relRev->getNext(); - $toContent = $toRev - ? $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ) - : $fromContent; - if ( !$toContent ) { - $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' ); - } + $toRev = $this->revisionStore->getNextRevision( $fromRelRev ); + $toRelRev = $toRev; + $toValsRev = $toRev; break; case 'cur': - $title = $relRev->getTitle(); - $id = $title->getLatestRevID(); - $toRev = $id ? Revision::newFromId( $id ) : null; + $title = $fromRelRev->getPageAsLinkTarget(); + $toRev = $this->revisionStore->getRevisionByTitle( $title ); if ( !$toRev ) { + $title = Title::newFromLinkTarget( $title ); $this->dieWithError( [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid' ); } - $toContent = $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ); - if ( !$toContent ) { - $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' ); - } + $toRelRev = $toRev; + $toValsRev = $toRev; break; } - $relRev2 = null; } else { - list( $toRev, $toContent, $relRev2 ) = $this->getDiffContent( 'to', $params ); + list( $toRev, $toRelRev, $toValsRev ) = $this->getDiffRevision( 'to', $params ); } - // Should never happen, but just in case... - if ( !$fromContent || !$toContent ) { + // Handle missing from or to revisions + if ( !$fromRev || !$toRev ) { $this->dieWithError( 'apierror-baddiff' ); } - // Extract sections, if told to - if ( isset( $params['fromsection'] ) ) { - $fromContent = $fromContent->getSection( $params['fromsection'] ); - if ( !$fromContent ) { - $this->dieWithError( - [ 'apierror-compare-nosuchfromsection', wfEscapeWikiText( $params['fromsection'] ) ], - 'nosuchfromsection' - ); - } + // Handle revdel + if ( !$fromRev->audienceCan( + RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_THIS_USER, $this->getUser() + ) ) { + $this->dieWithError( [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' ); } - if ( isset( $params['tosection'] ) ) { - $toContent = $toContent->getSection( $params['tosection'] ); - if ( !$toContent ) { - $this->dieWithError( - [ 'apierror-compare-nosuchtosection', wfEscapeWikiText( $params['tosection'] ) ], - 'nosuchtosection' - ); - } + if ( !$toRev->audienceCan( + RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_THIS_USER, $this->getUser() + ) ) { + $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' ); } // Get the diff $context = new DerivativeContext( $this->getContext() ); - if ( $relRev && $relRev->getTitle() ) { - $context->setTitle( $relRev->getTitle() ); - } elseif ( $relRev2 && $relRev2->getTitle() ) { - $context->setTitle( $relRev2->getTitle() ); + if ( $fromRelRev && $fromRelRev->getPageAsLinkTarget() ) { + $context->setTitle( Title::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() ) ); + } elseif ( $toRelRev && $toRelRev->getPageAsLinkTarget() ) { + $context->setTitle( Title::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() ) ); } else { - $this->guessTitleAndModel(); - if ( $this->guessedTitle ) { - $context->setTitle( $this->guessedTitle ); + $guessedTitle = $this->guessTitle(); + if ( $guessedTitle ) { + $context->setTitle( $guessedTitle ); } } - $de = $fromContent->getContentHandler()->createDifferenceEngine( - $context, - $fromRev ? $fromRev->getId() : 0, - $toRev ? $toRev->getId() : 0, - /* $rcid = */ null, - /* $refreshCache = */ false, - /* $unhide = */ true - ); - $de->setContent( $fromContent, $toContent ); - $difftext = $de->getDiffBody(); - if ( $difftext === false ) { - $this->dieWithError( 'apierror-baddiff' ); + $de = new DifferenceEngine( $context ); + $de->setRevisions( $fromRev, $toRev ); + if ( $params['slots'] === null ) { + $difftext = $de->getDiffBody(); + if ( $difftext === false ) { + $this->dieWithError( 'apierror-baddiff' ); + } + } else { + $difftext = []; + foreach ( $params['slots'] as $role ) { + $difftext[$role] = $de->getDiffBodyForRole( $role ); + } } // Fill in the response $vals = []; - $this->setVals( $vals, 'from', $fromRev ); - $this->setVals( $vals, 'to', $toRev ); + $this->setVals( $vals, 'from', $fromValsRev ); + $this->setVals( $vals, 'to', $toValsRev ); if ( isset( $this->props['rel'] ) ) { - if ( $fromRev ) { - $rev = $fromRev->getPrevious(); + if ( !$fromRev instanceof MutableRevisionRecord ) { + $rev = $this->revisionStore->getPreviousRevision( $fromRev ); if ( $rev ) { $vals['prev'] = $rev->getId(); } } - if ( $toRev ) { - $rev = $toRev->getNext(); + if ( !$toRev instanceof MutableRevisionRecord ) { + $rev = $this->revisionStore->getNextRevision( $toRev ); if ( $rev ) { $vals['next'] = $rev->getId(); } @@ -161,10 +156,18 @@ class ApiComparePages extends ApiBase { } if ( isset( $this->props['diffsize'] ) ) { - $vals['diffsize'] = strlen( $difftext ); + $vals['diffsize'] = 0; + foreach ( (array)$difftext as $text ) { + $vals['diffsize'] += strlen( $text ); + } } if ( isset( $this->props['diff'] ) ) { - ApiResult::setContentValue( $vals, 'body', $difftext ); + if ( is_array( $difftext ) ) { + ApiResult::setArrayType( $difftext, 'kvp', 'diff' ); + $vals['bodies'] = $difftext; + } else { + ApiResult::setContentValue( $vals, 'body', $difftext ); + } } // Diffs can be really big and there's little point in having @@ -174,49 +177,55 @@ class ApiComparePages extends ApiBase { } /** - * Guess an appropriate default Title and content model for this request + * Load a revision by ID * - * Fills in $this->guessedTitle based on the first of 'fromrev', - * 'fromtitle', 'fromid', 'torev', 'totitle', and 'toid' that's present and - * valid. + * Falls back to checking the archive table if appropriate. + * + * @param int $id + * @return RevisionRecord|null + */ + private function getRevisionById( $id ) { + $rev = $this->revisionStore->getRevisionById( $id ); + if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) { + // Try the 'archive' table + $arQuery = $this->revisionStore->getArchiveQueryInfo(); + $row = $this->getDB()->selectRow( + $arQuery['tables'], + array_merge( + $arQuery['fields'], + [ 'ar_namespace', 'ar_title' ] + ), + [ 'ar_rev_id' => $id ], + __METHOD__, + [], + $arQuery['joins'] + ); + if ( $row ) { + $rev = $this->revisionStore->newRevisionFromArchiveRow( $row ); + $rev->isArchive = true; + } + } + return $rev; + } + + /** + * Guess an appropriate default Title for this request * - * Fills in $this->guessedModel based on the Revision or Title used to - * determine $this->guessedTitle, or the 'fromcontentmodel' or - * 'tocontentmodel' parameters if no title was guessed. + * @return Title|null */ - private function guessTitleAndModel() { - if ( $this->guessed ) { - return; + private function guessTitle() { + if ( $this->guessedTitle !== false ) { + return $this->guessedTitle; } - $this->guessed = true; + $this->guessedTitle = null; $params = $this->extractRequestParams(); foreach ( [ 'from', 'to' ] as $prefix ) { if ( $params["{$prefix}rev"] !== null ) { - $revId = $params["{$prefix}rev"]; - $rev = Revision::newFromId( $revId ); - if ( !$rev ) { - // Titles of deleted revisions aren't secret, per T51088 - $arQuery = Revision::getArchiveQueryInfo(); - $row = $this->getDB()->selectRow( - $arQuery['tables'], - array_merge( - $arQuery['fields'], - [ 'ar_namespace', 'ar_title' ] - ), - [ 'ar_rev_id' => $revId ], - __METHOD__, - [], - $arQuery['joins'] - ); - if ( $row ) { - $rev = Revision::newFromArchiveRow( $row ); - } - } + $rev = $this->getRevisionById( $params["{$prefix}rev"] ); if ( $rev ) { - $this->guessedTitle = $rev->getTitle(); - $this->guessedModel = $rev->getContentModel(); + $this->guessedTitle = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); break; } } @@ -238,38 +247,84 @@ class ApiComparePages extends ApiBase { } } - if ( !$this->guessedModel ) { - if ( $this->guessedTitle ) { - $this->guessedModel = $this->guessedTitle->getContentModel(); - } elseif ( $params['fromcontentmodel'] !== null ) { - $this->guessedModel = $params['fromcontentmodel']; - } elseif ( $params['tocontentmodel'] !== null ) { - $this->guessedModel = $params['tocontentmodel']; + return $this->guessedTitle; + } + + /** + * Guess an appropriate default content model for this request + * @param string $role Slot for which to guess the model + * @return string|null Guessed content model + */ + private function guessModel( $role ) { + $params = $this->extractRequestParams(); + + $title = null; + foreach ( [ 'from', 'to' ] as $prefix ) { + if ( $params["{$prefix}rev"] !== null ) { + $rev = $this->getRevisionById( $params["{$prefix}rev"] ); + if ( $rev ) { + if ( $rev->hasSlot( $role ) ) { + return $rev->getSlot( $role, RevisionRecord::RAW )->getModel(); + } + } + } + } + + $guessedTitle = $this->guessTitle(); + if ( $guessedTitle && $role === 'main' ) { + // @todo: Use SlotRoleRegistry and do this for all slots + return $guessedTitle->getContentModel(); + } + + if ( isset( $params["fromcontentmodel-$role"] ) ) { + return $params["fromcontentmodel-$role"]; + } + if ( isset( $params["tocontentmodel-$role"] ) ) { + return $params["tocontentmodel-$role"]; + } + + if ( $role === 'main' ) { + if ( isset( $params['fromcontentmodel'] ) ) { + return $params['fromcontentmodel']; + } + if ( isset( $params['tocontentmodel'] ) ) { + return $params['tocontentmodel']; } } + + return null; } /** - * Get the Revision and Content for one side of the diff + * Get the RevisionRecord for one side of the diff * - * This uses the appropriate set of 'rev', 'id', 'title', 'text', 'pst', - * 'contentmodel', and 'contentformat' parameters to determine what content + * This uses the appropriate set of parameters to determine what content * should be diffed. * * Returns three values: - * - The revision used to retrieve the content, if any - * - The content to be diffed - * - The revision specified, if any, even if not used to retrieve the - * Content + * - A RevisionRecord holding the content + * - The revision specified, if any, even if content was supplied + * - The revision to pass to setVals(), if any * * @param string $prefix 'from' or 'to' * @param array $params - * @return array [ Revision|null, Content, Revision|null ] + * @return array [ RevisionRecord|null, RevisionRecord|null, RevisionRecord|null ] */ - private function getDiffContent( $prefix, array $params ) { + private function getDiffRevision( $prefix, array $params ) { + // Back compat params + $this->requireMaxOneParameter( $params, "{$prefix}text", "{$prefix}slots" ); + $this->requireMaxOneParameter( $params, "{$prefix}section", "{$prefix}slots" ); + if ( $params["{$prefix}text"] !== null ) { + $params["{$prefix}slots"] = [ 'main' ]; + $params["{$prefix}text-main"] = $params["{$prefix}text"]; + $params["{$prefix}section-main"] = null; + $params["{$prefix}contentmodel-main"] = $params["{$prefix}contentmodel"]; + $params["{$prefix}contentformat-main"] = $params["{$prefix}contentformat"]; + } + $title = null; $rev = null; - $suppliedContent = $params["{$prefix}text"] !== null; + $suppliedContent = $params["{$prefix}slots"] !== null; // Get the revision and title, if applicable $revId = null; @@ -308,94 +363,146 @@ class ApiComparePages extends ApiBase { } } if ( $revId !== null ) { - $rev = Revision::newFromId( $revId ); - if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) { - // Try the 'archive' table - $arQuery = Revision::getArchiveQueryInfo(); - $row = $this->getDB()->selectRow( - $arQuery['tables'], - array_merge( - $arQuery['fields'], - [ 'ar_namespace', 'ar_title' ] - ), - [ 'ar_rev_id' => $revId ], - __METHOD__, - [], - $arQuery['joins'] - ); - if ( $row ) { - $rev = Revision::newFromArchiveRow( $row ); - $rev->isArchive = true; - } - } + $rev = $this->getRevisionById( $revId ); if ( !$rev ) { $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] ); } - $title = $rev->getTitle(); + $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); // If we don't have supplied content, return here. Otherwise, // continue on below with the supplied content. if ( !$suppliedContent ) { - $content = $rev->getContent( Revision::FOR_THIS_USER, $this->getUser() ); - if ( !$content ) { - $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ], 'missingcontent' ); + $newRev = $rev; + + // Deprecated 'fromsection'/'tosection' + if ( isset( $params["{$prefix}section"] ) ) { + $section = $params["{$prefix}section"]; + $newRev = MutableRevisionRecord::newFromParentRevision( $rev ); + $content = $rev->getContent( 'main', RevisionRecord::FOR_THIS_USER, $this->getUser() ); + if ( !$content ) { + $this->dieWithError( + [ 'apierror-missingcontent-revid-role', $rev->getId(), 'main' ], 'missingcontent' + ); + } + $content = $content ? $content->getSection( $section ) : null; + if ( !$content ) { + $this->dieWithError( + [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ], + "nosuch{$prefix}section" + ); + } + $newRev->setContent( 'main', $content ); } - return [ $rev, $content, $rev ]; + + return [ $newRev, $rev, $rev ]; } } // Override $content based on supplied text - $model = $params["{$prefix}contentmodel"]; - $format = $params["{$prefix}contentformat"]; - - if ( !$model && $rev ) { - $model = $rev->getContentModel(); - } - if ( !$model && $title ) { - $model = $title->getContentModel(); - } - if ( !$model ) { - $this->guessTitleAndModel(); - $model = $this->guessedModel; - } - if ( !$model ) { - $model = CONTENT_MODEL_WIKITEXT; - $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] ); - } - if ( !$title ) { - $this->guessTitleAndModel(); - $title = $this->guessedTitle; + $title = $this->guessTitle(); } - - try { - $content = ContentHandler::makeContent( $params["{$prefix}text"], $title, $model, $format ); - } catch ( MWContentSerializationException $ex ) { - $this->dieWithException( $ex, [ - 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' ) + if ( $rev ) { + $newRev = MutableRevisionRecord::newFromParentRevision( $rev ); + } else { + $newRev = $this->revisionStore->newMutableRevisionFromArray( [ + 'title' => $title ?: Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __METHOD__ ) ] ); } + foreach ( $params["{$prefix}slots"] as $role ) { + $text = $params["{$prefix}text-{$role}"]; + if ( $text === null ) { + $newRev->removeSlot( $role ); + continue; + } + + $model = $params["{$prefix}contentmodel-{$role}"]; + $format = $params["{$prefix}contentformat-{$role}"]; - if ( $params["{$prefix}pst"] ) { - if ( !$title ) { - $this->dieWithError( 'apierror-compare-no-title' ); + if ( !$model && $rev && $rev->hasSlot( $role ) ) { + $model = $rev->getSlot( $role, RevisionRecord::RAW )->getModel(); + } + if ( !$model && $title && $role === 'main' ) { + // @todo: Use SlotRoleRegistry and do this for all slots + $model = $title->getContentModel(); + } + if ( !$model ) { + $model = $this->guessModel( $role ); + } + if ( !$model ) { + $model = CONTENT_MODEL_WIKITEXT; + $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] ); + } + + try { + $content = ContentHandler::makeContent( $text, $title, $model, $format ); + } catch ( MWContentSerializationException $ex ) { + $this->dieWithException( $ex, [ + 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' ) + ] ); + } + + if ( $params["{$prefix}pst"] ) { + if ( !$title ) { + $this->dieWithError( 'apierror-compare-no-title' ); + } + $popts = ParserOptions::newFromContext( $this->getContext() ); + $content = $content->preSaveTransform( $title, $this->getUser(), $popts ); + } + + $section = $params["{$prefix}section-{$role}"]; + if ( $section !== null && $section !== '' ) { + if ( !$rev ) { + $this->dieWithError( "apierror-compare-no{$prefix}revision" ); + } + $oldContent = $rev->getContent( $role, RevisionRecord::FOR_THIS_USER, $this->getUser() ); + if ( !$oldContent ) { + $this->dieWithError( + [ 'apierror-missingcontent-revid-role', $rev->getId(), wfEscapeWikiText( $role ) ], + 'missingcontent' + ); + } + if ( !$oldContent->getContentHandler()->supportsSections() ) { + $this->dieWithError( [ 'apierror-sectionsnotsupported', $content->getModel() ] ); + } + try { + $content = $oldContent->replaceSection( $section, $content, '' ); + } catch ( Exception $ex ) { + // Probably a content model mismatch. + $content = null; + } + if ( !$content ) { + $this->dieWithError( [ 'apierror-sectionreplacefailed' ] ); + } + } + + // Deprecated 'fromsection'/'tosection' + if ( $role === 'main' && isset( $params["{$prefix}section"] ) ) { + $section = $params["{$prefix}section"]; + $content = $content->getSection( $section ); + if ( !$content ) { + $this->dieWithError( + [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ], + "nosuch{$prefix}section" + ); + } } - $popts = ParserOptions::newFromContext( $this->getContext() ); - $content = $content->preSaveTransform( $title, $this->getUser(), $popts ); - } - return [ null, $content, $rev ]; + $newRev->setContent( $role, $content ); + } + return [ $newRev, $rev, null ]; } /** - * Set value fields from a Revision object + * Set value fields from a RevisionRecord object + * * @param array &$vals Result array to set data into * @param string $prefix 'from' or 'to' - * @param Revision|null $rev + * @param RevisionRecord|null $rev */ private function setVals( &$vals, $prefix, $rev ) { if ( $rev ) { - $title = $rev->getTitle(); + $title = $rev->getPageAsLinkTarget(); if ( isset( $this->props['ids'] ) ) { $vals["{$prefix}id"] = $title->getArticleID(); $vals["{$prefix}revid"] = $rev->getId(); @@ -408,41 +515,42 @@ class ApiComparePages extends ApiBase { } $anyHidden = false; - if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { $vals["{$prefix}texthidden"] = true; $anyHidden = true; } - if ( $rev->isDeleted( Revision::DELETED_USER ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) { $vals["{$prefix}userhidden"] = true; $anyHidden = true; } - if ( isset( $this->props['user'] ) && - $rev->userCan( Revision::DELETED_USER, $this->getUser() ) - ) { - $vals["{$prefix}user"] = $rev->getUserText( Revision::RAW ); - $vals["{$prefix}userid"] = $rev->getUser( Revision::RAW ); + if ( isset( $this->props['user'] ) ) { + $user = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getUser() ); + if ( $user ) { + $vals["{$prefix}user"] = $user->getName(); + $vals["{$prefix}userid"] = $user->getId(); + } } - if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_COMMENT ) ) { $vals["{$prefix}commenthidden"] = true; $anyHidden = true; } - if ( $rev->userCan( Revision::DELETED_COMMENT, $this->getUser() ) ) { - if ( isset( $this->props['comment'] ) ) { - $vals["{$prefix}comment"] = $rev->getComment( Revision::RAW ); - } - if ( isset( $this->props['parsedcomment'] ) ) { + if ( isset( $this->props['comment'] ) || isset( $this->props['parsedcomment'] ) ) { + $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getUser() ); + if ( $comment !== null ) { + if ( isset( $this->props['comment'] ) ) { + $vals["{$prefix}comment"] = $comment->text; + } $vals["{$prefix}parsedcomment"] = Linker::formatComment( - $rev->getComment( Revision::RAW ), - $rev->getTitle() + $comment->text, Title::newFromLinkTarget( $title ) ); } } if ( $anyHidden ) { $this->getMain()->setCacheMode( 'private' ); - if ( $rev->isDeleted( Revision::DELETED_RESTRICTED ) ) { + if ( $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) { $vals["{$prefix}suppressed"] = true; } } @@ -455,6 +563,12 @@ class ApiComparePages extends ApiBase { } public function getAllowedParams() { + $slotRoles = MediaWikiServices::getInstance()->getSlotRoleStore()->getMap(); + if ( !in_array( 'main', $slotRoles, true ) ) { + $slotRoles[] = 'main'; + } + sort( $slotRoles, SORT_STRING ); + // Parameters for the 'from' and 'to' content $fromToParams = [ 'title' => null, @@ -464,24 +578,58 @@ class ApiComparePages extends ApiBase { 'rev' => [ ApiBase::PARAM_TYPE => 'integer' ], - 'text' => [ - ApiBase::PARAM_TYPE => 'text' + + 'slots' => [ + ApiBase::PARAM_TYPE => $slotRoles, + ApiBase::PARAM_ISMULTI => true, + ], + 'text-{slot}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below + ApiBase::PARAM_TYPE => 'text', + ], + 'section-{slot}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below + ApiBase::PARAM_TYPE => 'string', + ], + 'contentformat-{slot}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below + ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(), + ], + 'contentmodel-{slot}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below + ApiBase::PARAM_TYPE => ContentHandler::getContentModels(), ], - 'section' => null, 'pst' => false, + + 'text' => [ + ApiBase::PARAM_TYPE => 'text', + ApiBase::PARAM_DEPRECATED => true, + ], 'contentformat' => [ ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(), + ApiBase::PARAM_DEPRECATED => true, ], 'contentmodel' => [ ApiBase::PARAM_TYPE => ContentHandler::getContentModels(), - ] + ApiBase::PARAM_DEPRECATED => true, + ], + 'section' => [ + ApiBase::PARAM_DFLT => null, + ApiBase::PARAM_DEPRECATED => true, + ], ]; $ret = []; foreach ( $fromToParams as $k => $v ) { + if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) { + $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'fromslots'; + } $ret["from$k"] = $v; } foreach ( $fromToParams as $k => $v ) { + if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) { + $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'toslots'; + } $ret["to$k"] = $v; } @@ -508,6 +656,12 @@ class ApiComparePages extends ApiBase { ApiBase::PARAM_HELP_MSG_PER_VALUE => [], ]; + $ret['slots'] = [ + ApiBase::PARAM_TYPE => $slotRoles, + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ALL => true, + ]; + return $ret; } diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 3c74f25feb..e29ddf91d9 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -64,20 +64,32 @@ "apihelp-compare-param-fromtitle": "First title to compare.", "apihelp-compare-param-fromid": "First page ID to compare.", "apihelp-compare-param-fromrev": "First revision to compare.", - "apihelp-compare-param-fromtext": "Use this text instead of the content of the revision specified by fromtitle, fromid or fromrev.", + "apihelp-compare-param-frompst": "Do a pre-save transform on fromtext-{slot}.", + "apihelp-compare-param-fromslots": "Override content of the revision specified by fromtitle, fromid or fromrev.\n\nThis parameter specifies the slots that are to be modified. Use fromtext-{slot}, fromcontentmodel-{slot}, and fromcontentformat-{slot} to specify content for each slot.", + "apihelp-compare-param-fromtext-{slot}": "Text of the specified slot. If omitted, the slot is removed from the revision.", + "apihelp-compare-param-fromsection-{slot}": "When fromtext-{slot} is the content of a single section, this is the section number. It will be merged into the revision specified by fromtitle, fromid or fromrev as if for a section edit.", + "apihelp-compare-param-fromcontentmodel-{slot}": "Content model of fromtext-{slot}. If not supplied, it will be guessed based on the other parameters.", + "apihelp-compare-param-fromcontentformat-{slot}": "Content serialization format of fromtext-{slot}.", + "apihelp-compare-param-fromtext": "Specify fromslots=main and use fromtext-main instead.", + "apihelp-compare-param-fromcontentmodel": "Specify fromslots=main and use fromcontentmodel-main instead.", + "apihelp-compare-param-fromcontentformat": "Specify fromslots=main and use fromcontentformat-main instead.", "apihelp-compare-param-fromsection": "Only use the specified section of the specified 'from' content.", - "apihelp-compare-param-frompst": "Do a pre-save transform on fromtext.", - "apihelp-compare-param-fromcontentmodel": "Content model of fromtext. If not supplied, it will be guessed based on the other parameters.", - "apihelp-compare-param-fromcontentformat": "Content serialization format of fromtext.", "apihelp-compare-param-totitle": "Second title to compare.", "apihelp-compare-param-toid": "Second page ID to compare.", "apihelp-compare-param-torev": "Second revision to compare.", "apihelp-compare-param-torelative": "Use a revision relative to the revision determined from fromtitle, fromid or fromrev. All of the other 'to' options will be ignored.", - "apihelp-compare-param-totext": "Use this text instead of the content of the revision specified by totitle, toid or torev.", - "apihelp-compare-param-tosection": "Only use the specified section of the specified 'to' content.", "apihelp-compare-param-topst": "Do a pre-save transform on totext.", - "apihelp-compare-param-tocontentmodel": "Content model of totext. If not supplied, it will be guessed based on the other parameters.", - "apihelp-compare-param-tocontentformat": "Content serialization format of totext.", + "apihelp-compare-param-toslots": "Override content of the revision specified by totitle, toid or torev.\n\nThis parameter specifies the slots that are to be modified. Use totext-{slot}, tocontentmodel-{slot}, and tocontentformat-{slot} to specify content for each slot.", + "apihelp-compare-param-totext-{slot}": "Text of the specified slot. If omitted, the slot is removed from the revision.", + "apihelp-compare-param-tosection-{slot}": "When totext-{slot} is the content of a single section, this is the section number. It will be merged into the revision specified by totitle, toid or torev as if for a section edit.", + "apihelp-compare-param-toslots": "Specify content to use instead of the content of the revision specified by totitle, toid or torev.\n\nThis parameter specifies the slots that have content. Use totext-{slot}, tocontentmodel-{slot}, and tocontentformat-{slot} to specify content for each slot.", + "apihelp-compare-param-totext-{slot}": "Text of the specified slot.", + "apihelp-compare-param-tocontentmodel-{slot}": "Content model of totext-{slot}. If not supplied, it will be guessed based on the other parameters.", + "apihelp-compare-param-tocontentformat-{slot}": "Content serialization format of totext-{slot}.", + "apihelp-compare-param-totext": "Specify toslots=main and use totext-main instead.", + "apihelp-compare-param-tocontentmodel": "Specify toslots=main and use tocontentmodel-main instead.", + "apihelp-compare-param-tocontentformat": "Specify toslots=main and use tocontentformat-main instead.", + "apihelp-compare-param-tosection": "Only use the specified section of the specified 'to' content.", "apihelp-compare-param-prop": "Which pieces of information to get.", "apihelp-compare-paramvalue-prop-diff": "The diff HTML.", "apihelp-compare-paramvalue-prop-diffsize": "The size of the diff HTML, in bytes.", @@ -88,6 +100,7 @@ "apihelp-compare-paramvalue-prop-comment": "The comment on the 'from' and 'to' revisions.", "apihelp-compare-paramvalue-prop-parsedcomment": "The parsed comment on the 'from' and 'to' revisions.", "apihelp-compare-paramvalue-prop-size": "The size of the 'from' and 'to' revisions.", + "apihelp-compare-param-slots": "Return individual diffs for these slots, rather than one combined diff for all slots.", "apihelp-compare-example-1": "Create a diff between revision 1 and 2.", "apihelp-createaccount-summary": "Create a new user account.", @@ -1709,6 +1722,8 @@ "apierror-compare-no-title": "Cannot pre-save transform without a title. Try specifying fromtitle or totitle.", "apierror-compare-nosuchfromsection": "There is no section $1 in the 'from' content.", "apierror-compare-nosuchtosection": "There is no section $1 in the 'to' content.", + "apierror-compare-nofromrevision": "No 'from' revision. Specify fromrev, fromtitle, or fromid.", + "apierror-compare-notorevision": "No 'to' revision. Specify torev, totitle, or toid.", "apierror-compare-relative-to-nothing": "No 'from' revision for torelative to be relative to.", "apierror-contentserializationexception": "Content serialization failed: $1", "apierror-contenttoobig": "The content you supplied exceeds the article size limit of $1 {{PLURAL:$1|kilobyte|kilobytes}}.", @@ -1756,6 +1771,7 @@ "apierror-mimesearchdisabled": "MIME search is disabled in Miser Mode.", "apierror-missingcontent-pageid": "Missing content for page ID $1.", "apierror-missingcontent-revid": "Missing content for revision ID $1.", + "apierror-missingcontent-revid-role": "Missing content for revision ID $1 for role $2.", "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|The parameter|At least one of the parameters}} $1 is required.", "apierror-missingparam-one-of": "{{PLURAL:$2|The parameter|One of the parameters}} $1 is required.", "apierror-missingparam": "The $1 parameter must be set.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index f158f27dd3..e58683a865 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -64,23 +64,33 @@ "apihelp-clientlogin-example-login2": "{{doc-apihelp-example|clientlogin}}", "apihelp-compare-summary": "{{doc-apihelp-summary|compare}}", "apihelp-compare-extended-description": "{{doc-apihelp-extended-description|compare}}", - "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}", + "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}", + "apihelp-compare-param-fromcontentformat-{slot}": "{{doc-apihelp-param|compare|fromcontentformat-{slot} }}", + "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}", + "apihelp-compare-param-fromcontentmodel-{slot}": "{{doc-apihelp-param|compare|fromcontentmodel-{slot} }}", "apihelp-compare-param-fromid": "{{doc-apihelp-param|compare|fromid}}", + "apihelp-compare-param-frompst": "{{doc-apihelp-param|compare|frompst}}", "apihelp-compare-param-fromrev": "{{doc-apihelp-param|compare|fromrev}}", - "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}", "apihelp-compare-param-fromsection": "{{doc-apihelp-param|compare|fromsection}}", - "apihelp-compare-param-frompst": "{{doc-apihelp-param|compare|frompst}}", - "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}", - "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}", - "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}", + "apihelp-compare-param-fromsection-{slot}": "{{doc-apihelp-param|compare|fromsection-{slot} }}", + "apihelp-compare-param-fromslots": "{{doc-apihelp-param|compare|fromslots}}", + "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}", + "apihelp-compare-param-fromtext-{slot}": "{{doc-apihelp-param|compare|fromtext-{slot} }}", + "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}", + "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}", + "apihelp-compare-param-tocontentformat-{slot}": "{{doc-apihelp-param|compare|tocontentformat-{slot} }}", + "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}", + "apihelp-compare-param-tocontentmodel-{slot}": "{{doc-apihelp-param|compare|tocontentmodel-{slot} }}", "apihelp-compare-param-toid": "{{doc-apihelp-param|compare|toid}}", - "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}", + "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}", "apihelp-compare-param-torelative": "{{doc-apihelp-param|compare|torelative}}", - "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}", + "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}", "apihelp-compare-param-tosection": "{{doc-apihelp-param|compare|tosection}}", - "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}", - "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}", - "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}", + "apihelp-compare-param-tosection-{slot}": "{{doc-apihelp-param|compare|tosection-{slot} }}", + "apihelp-compare-param-toslots": "{{doc-apihelp-param|compare|toslots}}", + "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}", + "apihelp-compare-param-totext-{slot}": "{{doc-apihelp-param|compare|totext-{slot} }}", + "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}", "apihelp-compare-param-prop": "{{doc-apihelp-param|compare|prop}}", "apihelp-compare-paramvalue-prop-diff": "{{doc-apihelp-paramvalue|compare|prop|diff}}", "apihelp-compare-paramvalue-prop-diffsize": "{{doc-apihelp-paramvalue|compare|prop|diffsize}}", @@ -91,6 +101,7 @@ "apihelp-compare-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|compare|prop|comment}}", "apihelp-compare-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|compare|prop|parsedcomment}}", "apihelp-compare-paramvalue-prop-size": "{{doc-apihelp-paramvalue|compare|prop|size}}", + "apihelp-compare-param-slots": "{{doc-apihelp-param|compare|slots}}", "apihelp-compare-example-1": "{{doc-apihelp-example|compare}}", "apihelp-createaccount-summary": "{{doc-apihelp-summary|createaccount}}", "apihelp-createaccount-param-preservestate": "{{doc-apihelp-param|createaccount|preservestate|info=This message is displayed in addition to {{msg-mw|api-help-authmanagerhelper-preservestate}}.}}", @@ -1595,8 +1606,10 @@ "apierror-chunk-too-small": "{{doc-apierror}}\n\nParameters:\n* $1 - Minimum size in bytes.", "apierror-cidrtoobroad": "{{doc-apierror}}\n\nParameters:\n* $1 - \"IPv4\" or \"IPv6\"\n* $2 - Minimum CIDR mask length.", "apierror-compare-no-title": "{{doc-apierror}}", + "apierror-compare-nofromrevision": "{{doc-apierror}}", "apierror-compare-nosuchfromsection": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.", "apierror-compare-nosuchtosection": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.", + "apierror-compare-notorevision": "{{doc-apierror}}", "apierror-compare-relative-to-nothing": "{{doc-apierror}}", "apierror-contentserializationexception": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text, may end with punctuation. Currently this is probably English, hopefully we'll fix that in the future.", "apierror-contenttoobig": "{{doc-apierror}}\n\nParameters:\n* $1 - Maximum article size in kilobytes.", @@ -1644,6 +1657,7 @@ "apierror-mimesearchdisabled": "{{doc-apierror}}", "apierror-missingcontent-pageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.", "apierror-missingcontent-revid": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number", + "apierror-missingcontent-revid-role": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number\n* $2 - Role name", "apierror-missingparam-at-least-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.", "apierror-missingparam-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.", "apierror-missingparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.", diff --git a/includes/diff/DifferenceEngine.php b/includes/diff/DifferenceEngine.php index 2ceda216d0..891f0fe9fe 100644 --- a/includes/diff/DifferenceEngine.php +++ b/includes/diff/DifferenceEngine.php @@ -1014,6 +1014,34 @@ class DifferenceEngine extends ContextSource { return $difftext; } + /** + * Get the diff table body for one slot, without header + * + * @param string $role + * @return string|false + */ + public function getDiffBodyForRole( $role ) { + $diffRenderers = $this->getSlotDiffRenderers(); + if ( !isset( $diffRenderers[$role] ) ) { + return false; + } + + $slotContents = $this->getSlotContents(); + $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'], + $slotContents[$role]['new'] ); + if ( !$slotDiff ) { + return false; + } + + if ( $role !== 'main' ) { + // TODO use human-readable role name at least + $slotTitle = $role; + $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff; + } + + return $this->localiseDiff( $slotDiff ); + } + /** * Get a slot header for inclusion in a diff body (as a table row). * diff --git a/tests/phpunit/includes/api/ApiComparePagesTest.php b/tests/phpunit/includes/api/ApiComparePagesTest.php index 04283357c6..30e1d0c618 100644 --- a/tests/phpunit/includes/api/ApiComparePagesTest.php +++ b/tests/phpunit/includes/api/ApiComparePagesTest.php @@ -73,6 +73,9 @@ class ApiComparePagesTest extends ApiTestCase { self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" ); self::$repl['pageF'] = Title::newFromText( 'ApiComparePagesTest F' )->getArticleId(); + self::$repl['revG1'] = $this->addPage( 'G', "== Section 1 ==\nG 1.1", CONTENT_MODEL_TEXT ); + self::$repl['pageG'] = Title::newFromText( 'ApiComparePagesTest G' )->getArticleId(); + WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) ) ->doDeleteArticleReal( 'Test for ApiComparePagesTest' ); @@ -132,6 +135,7 @@ class ApiComparePagesTest extends ApiTestCase { $params += [ 'action' => 'compare', + 'errorformat' => 'none', ]; $user = $sysop @@ -153,6 +157,25 @@ class ApiComparePagesTest extends ApiTestCase { } } + private static function makeDeprecationWarnings( ...$params ) { + $warn = []; + foreach ( $params as $p ) { + $warn[] = [ + 'code' => 'deprecation', + 'data' => [ 'feature' => "action=compare&{$p}" ], + 'module' => 'compare', + ]; + if ( count( $warn ) === 1 ) { + $warn[] = [ + 'code' => 'deprecation-help', + 'module' => 'main', + ]; + } + } + + return $warn; + } + public static function provideDiff() { // phpcs:disable Generic.Files.LineLength.TooLong return [ @@ -269,10 +292,12 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text' => [ [ - 'fromtext' => 'From text', - 'fromcontentmodel' => 'wikitext', - 'totext' => 'To text {{subst:PAGENAME}}', - 'tocontentmodel' => 'wikitext', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', + 'fromcontentmodel-main' => 'wikitext', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel-main' => 'wikitext', ], [ 'compare' => [ @@ -284,9 +309,11 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text 2' => [ [ - 'fromtext' => 'From text', - 'totext' => 'To text {{subst:PAGENAME}}', - 'tocontentmodel' => 'wikitext', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel-main' => 'wikitext', ], [ 'compare' => [ @@ -298,15 +325,13 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, guessed model' => [ [ - 'fromtext' => 'From text', - 'totext' => 'To text', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', + 'toslots' => 'main', + 'totext-main' => 'To text', ], [ - 'warnings' => [ - 'compare' => [ - 'warnings' => 'No content model could be determined, assuming wikitext.', - ], - ], + 'warnings' => [ [ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ] ], 'compare' => [ 'body' => 'Line 1:' . "\n" . 'Line 1:' . "\n" @@ -316,9 +341,11 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text with title and PST' => [ [ - 'fromtext' => 'From text', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', 'totitle' => 'Test', - 'totext' => 'To text {{subst:PAGENAME}}', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', 'topst' => true, ], [ @@ -331,9 +358,11 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text with page ID and PST' => [ [ - 'fromtext' => 'From text', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', 'toid' => '{{REPL:pageB}}', - 'totext' => 'To text {{subst:PAGENAME}}', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', 'topst' => true, ], [ @@ -346,9 +375,11 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text with revision and PST' => [ [ - 'fromtext' => 'From text', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', 'torev' => '{{REPL:revB2}}', - 'totext' => 'To text {{subst:PAGENAME}}', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', 'topst' => true, ], [ @@ -361,9 +392,11 @@ class ApiComparePagesTest extends ApiTestCase { ], 'Basic diff, text with deleted revision and PST' => [ [ - 'fromtext' => 'From text', + 'fromslots' => 'main', + 'fromtext-main' => 'From text', 'torev' => '{{REPL:revC2}}', - 'totext' => 'To text {{subst:PAGENAME}}', + 'toslots' => 'main', + 'totext-main' => 'To text {{subst:PAGENAME}}', 'topst' => true, ], [ @@ -378,20 +411,23 @@ class ApiComparePagesTest extends ApiTestCase { 'Basic diff, test with sections' => [ [ 'fromtitle' => 'ApiComparePagesTest F', - 'fromsection' => 1, - 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?", - 'tosection' => 2, + 'fromslots' => 'main', + 'fromtext-main' => "== Section 2 ==\nFrom text?", + 'fromsection-main' => 2, + 'totitle' => 'ApiComparePagesTest F', + 'toslots' => 'main', + 'totext-main' => "== Section 1 ==\nTo text?", + 'tosection-main' => 1, ], [ 'compare' => [ 'body' => 'Line 1:' . "\n" . 'Line 1:' . "\n" - . '−
== Section 1 ==
+
== Section 2 ==
' . "\n" - . '−
F 1.1
+
To text?
' . "\n", - 'fromid' => '{{REPL:pageF}}', - 'fromrevid' => '{{REPL:revF1}}', - 'fromns' => '0', - 'fromtitle' => 'ApiComparePagesTest F', + . ' 
== Section 1 ==
 
== Section 1 ==
' . "\n" + . '−
F 1.1
+
To text?
' . "\n" + . '  ' . "\n" + . ' 
== Section 2 ==
 
== Section 2 ==
' . "\n" + . '−
From text?
+
F 1.2
' . "\n", ] ], ], @@ -517,6 +553,197 @@ class ApiComparePagesTest extends ApiTestCase { ] ], ], + 'Diff for specific slots' => [ + // @todo Use a page with multiple slots here + [ + 'fromrev' => '{{REPL:revA1}}', + 'torev' => '{{REPL:revA3}}', + 'prop' => 'diff', + 'slots' => 'main', + ], + [ + 'compare' => [ + 'bodies' => [ + 'main' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 1
+
A 3
' . "\n", + ], + ], + ], + ], + // @todo Add a test for diffing with a deleted slot. Deleting 'main' doesn't work. + + 'Basic diff, deprecated text' => [ + [ + 'fromtext' => 'From text', + 'fromcontentmodel' => 'wikitext', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'fromcontentmodel', 'totext', 'tocontentmodel' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text {{subst:PAGENAME}}
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text 2' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext', 'tocontentmodel' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text {{subst:PAGENAME}}
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text, guessed model' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text', + ], + [ + 'warnings' => array_merge( self::makeDeprecationWarnings( 'fromtext', 'totext' ), [ + [ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ], + ] ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text with title and PST' => [ + [ + 'fromtext' => 'From text', + 'totitle' => 'Test', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text Test
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text with page ID and PST' => [ + [ + 'fromtext' => 'From text', + 'toid' => '{{REPL:pageB}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest B
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text with revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revB2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest B
' . "\n", + ] + ], + ], + 'Basic diff, deprecated text with deleted revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revC2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest C
' . "\n", + ] + ], + false, true + ], + 'Basic diff, test with deprecated sections' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'fromsection' => 1, + 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?", + 'tosection' => 2, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromsection', 'totext', 'tosection' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
== Section 1 ==
+
== Section 2 ==
' . "\n" + . '−
F 1.1
+
To text?
' . "\n", + 'fromid' => '{{REPL:pageF}}', + 'fromrevid' => '{{REPL:revF1}}', + 'fromns' => '0', + 'fromtitle' => 'ApiComparePagesTest F', + ] + ], + ], + 'Basic diff, test with deprecated sections and revdel, non-sysop' => [ + [ + 'fromrev' => '{{REPL:revB2}}', + 'fromsection' => 0, + 'torev' => '{{REPL:revB4}}', + 'tosection' => 0, + ], + [], + 'missingcontent' + ], + 'Basic diff, test with deprecated sections and revdel, sysop' => [ + [ + 'fromrev' => '{{REPL:revB2}}', + 'fromsection' => 0, + 'torev' => '{{REPL:revB4}}', + 'tosection' => 0, + ], + [ + 'warnings' => self::makeDeprecationWarnings( 'fromsection', 'tosection' ), + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
B 2
+
B 4
' . "\n", + 'fromid' => '{{REPL:pageB}}', + 'fromrevid' => '{{REPL:revB2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest B', + 'fromtexthidden' => true, + 'fromuserhidden' => true, + 'fromcommenthidden' => true, + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB4}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + ] + ], + false, true, + ], 'Error, missing title' => [ [ @@ -647,6 +874,68 @@ class ApiComparePagesTest extends ApiTestCase { [], 'missingcontent' ], + 'Error, Relative diff, no prev' => [ + [ + 'fromrev' => '{{REPL:revA1}}', + 'torelative' => 'prev', + 'prop' => 'ids', + ], + [], + 'baddiff' + ], + 'Error, Relative diff, no next' => [ + [ + 'fromrev' => '{{REPL:revA4}}', + 'torelative' => 'next', + 'prop' => 'ids', + ], + [], + 'baddiff' + ], + 'Error, section diff with no revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'toslots' => 'main', + 'totext-main' => "== Section 1 ==\nTo text?", + 'tosection-main' => 1, + ], + [], + 'compare-notorevision', + ], + 'Error, section diff with revdeleted revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'torev' => '{{REPL:revB2}}', + 'toslots' => 'main', + 'totext-main' => "== Section 1 ==\nTo text?", + 'tosection-main' => 1, + ], + [], + 'missingcontent', + ], + 'Error, section diff with a content model not supporting sections' => [ + [ + 'fromtitle' => 'ApiComparePagesTest G', + 'torev' => '{{REPL:revG1}}', + 'toslots' => 'main', + 'totext-main' => "== Section 1 ==\nTo text?", + 'tosection-main' => 1, + ], + [], + 'sectionsnotsupported', + ], + 'Error, section diff with bad content model' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'torev' => '{{REPL:revF1}}', + 'toslots' => 'main', + 'totext-main' => "== Section 1 ==\nTo text?", + 'tosection-main' => 1, + 'tocontentmodel-main' => CONTENT_MODEL_TEXT, + ], + [], + 'sectionreplacefailed', + ], ]; // phpcs:enable } -- 2.20.1