From c671ee16222dfb56fbd0ad8dab4684b8fd8bc09d Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Thu, 4 May 2017 18:28:23 -0400 Subject: [PATCH] API: Rewrite ApiComparePages Support diffing deleted revisions, user-supplied text, and additional properties about the diffed revisions such as the user and edit summary. Bug: T20189 Bug: T30047 Bug: T164529 Change-Id: I5565d717d9c2fd19da7cae02890a15e244cc238b --- RELEASE-NOTES-1.30 | 2 + includes/api/ApiComparePages.php | 481 ++++++++++++-- includes/api/i18n/en.json | 27 +- includes/api/i18n/qqq.json | 29 +- .../includes/api/ApiComparePagesTest.php | 611 ++++++++++++++++++ 5 files changed, 1086 insertions(+), 64 deletions(-) create mode 100644 tests/phpunit/includes/api/ApiComparePagesTest.php diff --git a/RELEASE-NOTES-1.30 b/RELEASE-NOTES-1.30 index 22fed0c84a..fc493514a9 100644 --- a/RELEASE-NOTES-1.30 +++ b/RELEASE-NOTES-1.30 @@ -60,6 +60,8 @@ production. the new 'wrapoutputclass' parameter. * When errorformat is not 'bc', abort reasons from action=login will be formatted as specified by the error formatter parameters. +* action=compare can now handle arbitrary text, deleted revisions, and + returning users and edit comments. === Action API internal changes in 1.30 === * … diff --git a/includes/api/ApiComparePages.php b/includes/api/ApiComparePages.php index d6867eb52d..953bc10cc3 100644 --- a/includes/api/ApiComparePages.php +++ b/includes/api/ApiComparePages.php @@ -1,9 +1,5 @@ extractRequestParams(); - $rev1 = $this->revisionOrTitleOrId( $params['fromrev'], $params['fromtitle'], $params['fromid'] ); - $rev2 = $this->revisionOrTitleOrId( $params['torev'], $params['totitle'], $params['toid'] ); + // Parameter validation + $this->requireAtLeastOneParameter( $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext' ); + $this->requireAtLeastOneParameter( $params, 'totitle', 'toid', 'torev', 'totext', 'torelative' ); + + $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 ); - $revision = Revision::newFromId( $rev1 ); + // Get the 'to' Revision and Content + if ( $params['torelative'] !== null ) { + if ( !$relRev ) { + $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' + ); + } + 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' ); + } + break; + + case 'cur': + $title = $relRev->getTitle(); + $id = $title->getLatestRevID(); + $toRev = $id ? Revision::newFromId( $id ) : null; + if ( !$toRev ) { + $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' ); + } + break; + } + $relRev2 = null; + } else { + list( $toRev, $toContent, $relRev2 ) = $this->getDiffContent( 'to', $params ); + } - if ( !$revision ) { + // Should never happen, but just in case... + if ( !$fromContent || !$toContent ) { $this->dieWithError( 'apierror-baddiff' ); } - $contentHandler = $revision->getContentHandler(); - $de = $contentHandler->createDifferenceEngine( $this->getContext(), - $rev1, - $rev2, - null, // rcid - true, - false ); + // Get the diff + $context = new DerivativeContext( $this->getContext() ); + if ( $relRev && $relRev->getTitle() ) { + $context->setTitle( $relRev->getTitle() ); + } elseif ( $relRev2 && $relRev2->getTitle() ) { + $context->setTitle( $relRev2->getTitle() ); + } else { + $this->guessTitleAndModel(); + if ( $this->guessedTitle ) { + $context->setTitle( $this->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' ); + } + // Fill in the response $vals = []; - if ( isset( $params['fromtitle'] ) ) { - $vals['fromtitle'] = $params['fromtitle']; - } - if ( isset( $params['fromid'] ) ) { - $vals['fromid'] = $params['fromid']; + $this->setVals( $vals, 'from', $fromRev ); + $this->setVals( $vals, 'to', $toRev ); + + if ( isset( $this->props['rel'] ) ) { + if ( $fromRev ) { + $rev = $fromRev->getPrevious(); + if ( $rev ) { + $vals['prev'] = $rev->getId(); + } + } + if ( $toRev ) { + $rev = $toRev->getNext(); + if ( $rev ) { + $vals['next'] = $rev->getId(); + } + } } - $vals['fromrevid'] = $rev1; - if ( isset( $params['totitle'] ) ) { - $vals['totitle'] = $params['totitle']; + + if ( isset( $this->props['diffsize'] ) ) { + $vals['diffsize'] = strlen( $difftext ); } - if ( isset( $params['toid'] ) ) { - $vals['toid'] = $params['toid']; + if ( isset( $this->props['diff'] ) ) { + ApiResult::setContentValue( $vals, 'body', $difftext ); } - $vals['torevid'] = $rev2; - $difftext = $de->getDiffBody(); + $this->getResult()->addValue( null, $this->getModuleName(), $vals ); + } - if ( $difftext === false ) { - $this->dieWithError( 'apierror-baddiff' ); + /** + * Guess an appropriate default Title and content model for this request + * + * Fills in $this->guessedTitle based on the first of 'fromrev', + * 'fromtitle', 'fromid', 'torev', 'totitle', and 'toid' that's present and + * valid. + * + * 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. + */ + private function guessTitleAndModel() { + if ( $this->guessed ) { + return; } - ApiResult::setContentValue( $vals, 'body', $difftext ); + $this->guessed = true; + $params = $this->extractRequestParams(); - $this->getResult()->addValue( null, $this->getModuleName(), $vals ); + 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 + $row = $this->getDB()->selectRow( + 'archive', + array_merge( + Revision::selectArchiveFields(), + [ 'ar_namespace', 'ar_title' ] + ), + [ 'ar_rev_id' => $revId ], + __METHOD__ + ); + if ( $row ) { + $rev = Revision::newFromArchiveRow( $row ); + } + } + if ( $rev ) { + $this->guessedTitle = $rev->getTitle(); + $this->guessedModel = $rev->getContentModel(); + break; + } + } + + if ( $params["{$prefix}title"] !== null ) { + $title = Title::newFromText( $params["{$prefix}title"] ); + if ( $title && !$title->isExternal() ) { + $this->guessedTitle = $title; + break; + } + } + + if ( $params["{$prefix}id"] !== null ) { + $title = Title::newFromID( $params["{$prefix}id"] ); + if ( $title ) { + $this->guessedTitle = $title; + break; + } + } + } + + 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']; + } + } } /** - * @param int $revision - * @param string $titleText - * @param int $titleId - * @return int + * Get the Revision and Content 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 + * 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 + * + * @param string $prefix 'from' or 'to' + * @param array $params + * @return array [ Revision|null, Content, Revision|null ] */ - private function revisionOrTitleOrId( $revision, $titleText, $titleId ) { - if ( $revision ) { - return $revision; - } elseif ( $titleText ) { - $title = Title::newFromText( $titleText ); - if ( !$title || $title->isExternal() ) { - $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $titleText ) ] ); - } - - return $title->getLatestRevID(); - } elseif ( $titleId ) { - $title = Title::newFromID( $titleId ); + private function getDiffContent( $prefix, array $params ) { + $title = null; + $rev = null; + $suppliedContent = $params["{$prefix}text"] !== null; + + // Get the revision and title, if applicable + $revId = null; + if ( $params["{$prefix}rev"] !== null ) { + $revId = $params["{$prefix}rev"]; + } elseif ( $params["{$prefix}title"] !== null || $params["{$prefix}id"] !== null ) { + if ( $params["{$prefix}title"] !== null ) { + $title = Title::newFromText( $params["{$prefix}title"] ); + if ( !$title || $title->isExternal() ) { + $this->dieWithError( + [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ] + ); + } + } else { + $title = Title::newFromID( $params["{$prefix}id"] ); + if ( !$title ) { + $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] ); + } + } + $revId = $title->getLatestRevID(); + if ( !$revId ) { + $revId = null; + // Only die here if we're not using supplied text + if ( !$suppliedContent ) { + if ( $title->exists() ) { + $this->dieWithError( + [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid' + ); + } else { + $this->dieWithError( + [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ], + 'missingtitle' + ); + } + } + } + } + if ( $revId !== null ) { + $rev = Revision::newFromId( $revId ); + if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) { + // Try the 'archive' table + $row = $this->getDB()->selectRow( + 'archive', + array_merge( + Revision::selectArchiveFields(), + [ 'ar_namespace', 'ar_title' ] + ), + [ 'ar_rev_id' => $revId ], + __METHOD__ + ); + if ( $row ) { + $rev = Revision::newFromArchiveRow( $row ); + $rev->isArchive = true; + } + } + if ( !$rev ) { + $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] ); + } + $title = $rev->getTitle(); + + // 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' ); + } + return [ $rev, $content, $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; + } + + try { + $content = ContentHandler::makeContent( $params["{$prefix}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-nosuchpageid', $titleId ] ); + $this->dieWithError( 'apierror-compare-no-title' ); + } + $popts = ParserOptions::newFromContext( $this->getContext() ); + $content = $content->preSaveTransform( $title, $this->getUser(), $popts ); + } + + return [ null, $content, $rev ]; + } + + /** + * Set value fields from a Revision object + * @param array &$vals Result array to set data into + * @param string $prefix 'from' or 'to' + * @param Revision|null $rev + */ + private function setVals( &$vals, $prefix, $rev ) { + if ( $rev ) { + $title = $rev->getTitle(); + if ( isset( $this->props['ids'] ) ) { + $vals["{$prefix}id"] = $title->getArticleId(); + $vals["{$prefix}revid"] = $rev->getId(); + } + if ( isset( $this->props['title'] ) ) { + ApiQueryBase::addTitleInfo( $vals, $title, $prefix ); + } + if ( isset( $this->props['size'] ) ) { + $vals["{$prefix}size"] = $rev->getSize(); } - return $title->getLatestRevID(); + $anyHidden = false; + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $vals["{$prefix}texthidden"] = true; + $anyHidden = true; + } + + if ( $rev->isDeleted( Revision::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 ( $rev->isDeleted( Revision::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'] ) ) { + $vals["{$prefix}parsedcomment"] = Linker::formatComment( + $rev->getComment( Revision::RAW ), + $rev->getTitle() + ); + } + } + + if ( $anyHidden ) { + $this->getMain()->setCacheMode( 'private' ); + if ( $rev->isDeleted( Revision::DELETED_RESTRICTED ) ) { + $vals["{$prefix}suppressed"] = true; + } + } + + if ( !empty( $rev->isArchive ) ) { + $this->getMain()->setCacheMode( 'private' ); + $vals["{$prefix}archive"] = true; + } } - $this->dieWithError( 'apierror-compare-inputneeded', 'inputneeded' ); } public function getAllowedParams() { - return [ - 'fromtitle' => null, - 'fromid' => [ + // Parameters for the 'from' and 'to' content + $fromToParams = [ + 'title' => null, + 'id' => [ ApiBase::PARAM_TYPE => 'integer' ], - 'fromrev' => [ + 'rev' => [ ApiBase::PARAM_TYPE => 'integer' ], - 'totitle' => null, - 'toid' => [ - ApiBase::PARAM_TYPE => 'integer' + 'text' => [ + ApiBase::PARAM_TYPE => 'text' ], - 'torev' => [ - ApiBase::PARAM_TYPE => 'integer' + 'pst' => false, + 'contentformat' => [ + ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(), ], + 'contentmodel' => [ + ApiBase::PARAM_TYPE => ContentHandler::getContentModels(), + ] ]; + + $ret = []; + foreach ( $fromToParams as $k => $v ) { + $ret["from$k"] = $v; + } + foreach ( $fromToParams as $k => $v ) { + $ret["to$k"] = $v; + } + + $ret = wfArrayInsertAfter( + $ret, + [ 'torelative' => [ ApiBase::PARAM_TYPE => [ 'prev', 'next', 'cur' ], ] ], + 'torev' + ); + + $ret['prop'] = [ + ApiBase::PARAM_DFLT => 'diff|ids|title', + ApiBase::PARAM_TYPE => [ + 'diff', + 'diffsize', + 'rel', + 'ids', + 'title', + 'user', + 'comment', + 'parsedcomment', + 'size', + ], + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_HELP_MSG_PER_VALUE => [], + ]; + + return $ret; } protected function getExamplesMessages() { diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 967026064b..ed3f25f3d9 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -57,13 +57,32 @@ "apihelp-clientlogin-example-login": "Start the process of logging in to the wiki as user Example with password ExamplePassword.", "apihelp-clientlogin-example-login2": "Continue logging in after a UI response for two-factor auth, supplying an OATHToken of 987654.", - "apihelp-compare-description": "Get the difference between 2 pages.\n\nA revision number, a page title, or a page ID for both \"from\" and \"to\" must be passed.", + "apihelp-compare-description": "Get the difference between two pages.\n\nA revision number, a page title, a page ID, text, or a relative reference for both \"from\" and \"to\" must be passed.", "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.", + "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-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-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.", + "apihelp-compare-paramvalue-prop-rel": "The revision IDs of the revision previous to 'from' and after 'to', if any.", + "apihelp-compare-paramvalue-prop-ids": "The page and revision IDs of the 'from' and 'to' revisions.", + "apihelp-compare-paramvalue-prop-title": "The page titles of the 'from' and 'to' revisions.", + "apihelp-compare-paramvalue-prop-user": "The user name and ID of the 'from' and 'to' revisions.", + "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-example-1": "Create a diff between revision 1 and 2.", "apihelp-createaccount-description": "Create a new user account.", @@ -1622,7 +1641,8 @@ "apierror-changeauth-norequest": "Failed to create change request.", "apierror-chunk-too-small": "Minimum chunk size is $1 {{PLURAL:$1|byte|bytes}} for non-final chunks.", "apierror-cidrtoobroad": "$1 CIDR ranges broader than /$2 are not accepted.", - "apierror-compare-inputneeded": "A title, a page ID, or a revision number is needed for both the from and the to parameters.", + "apierror-compare-no-title": "Cannot pre-save transform without a title. Try specifying fromtitle or totitle.", + "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}}.", "apierror-copyuploadbaddomain": "Uploads by URL are not allowed from this domain.", @@ -1666,10 +1686,12 @@ "apierror-maxlag": "Waiting for $2: $1 {{PLURAL:$1|second|seconds}} lagged.", "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-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.", "apierror-missingrev-pageid": "No current revision of page ID $1.", + "apierror-missingrev-title": "No current revision of title $1.", "apierror-missingtitle-createonly": "Missing titles can only be protected with create.", "apierror-missingtitle": "The page you specified doesn't exist.", "apierror-missingtitle-byname": "The page $1 doesn't exist.", @@ -1773,6 +1795,7 @@ "apiwarn-badurlparam": "Could not parse $1urlparam for $2. Using only width and height.", "apiwarn-badutf8": "The value passed for $1 contains invalid or non-normalized data. Textual data should be valid, NFC-normalized Unicode without C0 control characters other than HT (\\t), LF (\\n), and CR (\\r).", "apiwarn-checktoken-percentencoding": "Check that symbols such as \"+\" in the token are properly percent-encoded in the URL.", + "apiwarn-compare-nocontentmodel": "No content model could be determined, assuming $1.", "apiwarn-deprecation-deletedrevs": "list=deletedrevs has been deprecated. Please use prop=deletedrevisions or list=alldeletedrevisions instead.", "apiwarn-deprecation-expandtemplates-prop": "Because no values have been specified for the prop parameter, a legacy format has been used for the output. This format is deprecated, and in the future, a default value will be set for the prop parameter, causing the new format to always be used.", "apiwarn-deprecation-httpsexpected": "HTTP used when HTTPS was expected.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index adea9abf02..83246fbb54 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -62,12 +62,31 @@ "apihelp-clientlogin-example-login": "{{doc-apihelp-example|clientlogin}}", "apihelp-clientlogin-example-login2": "{{doc-apihelp-example|clientlogin}}", "apihelp-compare-description": "{{doc-apihelp-description|compare}}", - "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}", + "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}", + "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}", "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-totitle": "{{doc-apihelp-param|compare|totitle}}", + "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}", + "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}", + "apihelp-compare-param-prop": "{{doc-apihelp-param|compare|prop}}", + "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}", + "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}", "apihelp-compare-param-toid": "{{doc-apihelp-param|compare|toid}}", + "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}", + "apihelp-compare-param-torelative": "{{doc-apihelp-param|compare|torelative}}", "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}", + "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}", + "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}", + "apihelp-compare-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|compare|prop|comment}}", + "apihelp-compare-paramvalue-prop-diff": "{{doc-apihelp-paramvalue|compare|prop|diff}}", + "apihelp-compare-paramvalue-prop-diffsize": "{{doc-apihelp-paramvalue|compare|prop|diffsize}}", + "apihelp-compare-paramvalue-prop-ids": "{{doc-apihelp-paramvalue|compare|prop|ids}}", + "apihelp-compare-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|compare|prop|parsedcomment}}", + "apihelp-compare-paramvalue-prop-rel": "{{doc-apihelp-paramvalue|compare|prop|rel}}", + "apihelp-compare-paramvalue-prop-size": "{{doc-apihelp-paramvalue|compare|prop|size}}", + "apihelp-compare-paramvalue-prop-title": "{{doc-apihelp-paramvalue|compare|prop|title}}", + "apihelp-compare-paramvalue-prop-user": "{{doc-apihelp-paramvalue|compare|prop|user}}", "apihelp-compare-example-1": "{{doc-apihelp-example|compare}}", "apihelp-createaccount-description": "{{doc-apihelp-description|createaccount}}", "apihelp-createaccount-param-preservestate": "{{doc-apihelp-param|createaccount|preservestate|info=This message is displayed in addition to {{msg-mw|api-help-authmanagerhelper-preservestate}}.}}", @@ -1513,7 +1532,8 @@ "apierror-changeauth-norequest": "{{doc-apierror}}", "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-inputneeded": "{{doc-apierror}}", + "apierror-compare-no-title": "{{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.", "apierror-copyuploadbaddomain": "{{doc-apierror}}", @@ -1557,10 +1577,12 @@ "apierror-maxlag": "{{doc-apierror}}\n\nParameters:\n* $1 - Database lag in seconds.\n* $2 - Database server that is lagged.", "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-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.", "apierror-missingrev-pageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.", + "apierror-missingrev-title": "{{doc-apierror}}\n\nParameters:\n* $1 - Page title.", "apierror-missingtitle-createonly": "{{doc-apierror}}", "apierror-missingtitle": "{{doc-apierror}}", "apierror-missingtitle-byname": "{{doc-apierror}}", @@ -1663,6 +1685,7 @@ "apiwarn-badurlparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Module parameter prefix, e.g. \"bl\".\n* $2 - Image title.", "apiwarn-badutf8": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n{{doc-important|Do not translate \"\\t\", \"\\n\", and \"\\r\"}}", "apiwarn-checktoken-percentencoding": "{{doc-apierror}}", + "apiwarn-compare-nocontentmodel": "{{doc-apierror}}\n\nParameters:\n* $1 - Content model being assumed.", "apiwarn-deprecation-deletedrevs": "{{doc-apierror}}", "apiwarn-deprecation-expandtemplates-prop": "{{doc-apierror}}", "apiwarn-deprecation-httpsexpected": "{{doc-apierror}}", diff --git a/tests/phpunit/includes/api/ApiComparePagesTest.php b/tests/phpunit/includes/api/ApiComparePagesTest.php new file mode 100644 index 0000000000..989d6bb536 --- /dev/null +++ b/tests/phpunit/includes/api/ApiComparePagesTest.php @@ -0,0 +1,611 @@ +setMwGlobals( [ + 'wgExternalDiffEngine' => '/dev/null', + ] ); + } + + protected function addPage( $page, $text, $model = CONTENT_MODEL_WIKITEXT ) { + $title = Title::newFromText( 'ApiComparePagesTest ' . $page ); + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $user = static::getTestSysop()->getUser(); + $status = $page->doEditContent( + $content, 'Test for ApiComparePagesTest: ' . $text, 0, false, $user + ); + if ( !$status->isOk() ) { + $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) ); + } + return $status->value['revision']->getId(); + } + + public function addDBDataOnce() { + $user = static::getTestSysop()->getUser(); + self::$repl['creator'] = $user->getName(); + self::$repl['creatorid'] = $user->getId(); + + self::$repl['revA1'] = $this->addPage( 'A', 'A 1' ); + self::$repl['revA2'] = $this->addPage( 'A', 'A 2' ); + self::$repl['revA3'] = $this->addPage( 'A', 'A 3' ); + self::$repl['revA4'] = $this->addPage( 'A', 'A 4' ); + self::$repl['pageA'] = Title::newFromText( 'ApiComparePagesTest A' )->getArticleId(); + + self::$repl['revB1'] = $this->addPage( 'B', 'B 1' ); + self::$repl['revB2'] = $this->addPage( 'B', 'B 2' ); + self::$repl['revB3'] = $this->addPage( 'B', 'B 3' ); + self::$repl['revB4'] = $this->addPage( 'B', 'B 4' ); + self::$repl['pageB'] = Title::newFromText( 'ApiComparePagesTest B' )->getArticleId(); + + self::$repl['revC1'] = $this->addPage( 'C', 'C 1' ); + self::$repl['revC2'] = $this->addPage( 'C', 'C 2' ); + self::$repl['revC3'] = $this->addPage( 'C', 'C 3' ); + self::$repl['pageC'] = Title::newFromText( 'ApiComparePagesTest C' )->getArticleId(); + + $id = $this->addPage( 'D', 'D 1' ); + self::$repl['pageD'] = Title::newFromText( 'ApiComparePagesTest D' )->getArticleId(); + wfGetDB( DB_MASTER )->delete( 'revision', [ 'rev_id' => $id ] ); + + self::$repl['revE1'] = $this->addPage( 'E', 'E 1' ); + self::$repl['revE2'] = $this->addPage( 'E', 'E 2' ); + self::$repl['revE3'] = $this->addPage( 'E', 'E 3' ); + self::$repl['revE4'] = $this->addPage( 'E', 'E 4' ); + self::$repl['pageE'] = Title::newFromText( 'ApiComparePagesTest E' )->getArticleId(); + wfGetDB( DB_MASTER )->update( + 'page', [ 'page_latest' => 0 ], [ 'page_id' => self::$repl['pageE'] ] + ); + + WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) ) + ->doDeleteArticleReal( 'Test for ApiComparePagesTest' ); + + RevisionDeleter::createList( + 'revision', + RequestContext::getMain(), + Title::newFromText( 'ApiComparePagesTest B' ), + [ self::$repl['revB2'] ] + )->setVisibility( [ + 'value' => [ + Revision::DELETED_TEXT => 1, + Revision::DELETED_USER => 1, + Revision::DELETED_COMMENT => 1, + ], + 'comment' => 'Test for ApiComparePages', + ] ); + + RevisionDeleter::createList( + 'revision', + RequestContext::getMain(), + Title::newFromText( 'ApiComparePagesTest B' ), + [ self::$repl['revB3'] ] + )->setVisibility( [ + 'value' => [ + Revision::DELETED_USER => 1, + Revision::DELETED_COMMENT => 1, + Revision::DELETED_RESTRICTED => 1, + ], + 'comment' => 'Test for ApiComparePages', + ] ); + + Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason + } + + protected function doReplacements( &$value ) { + if ( is_string( $value ) ) { + if ( preg_match( '/^{{REPL:(.+?)}}$/', $value, $m ) ) { + $value = self::$repl[$m[1]]; + } else { + $value = preg_replace_callback( '/{{REPL:(.+?)}}/', function ( $m ) { + return isset( self::$repl[$m[1]] ) ? self::$repl[$m[1]] : $m[0]; + }, $value ); + } + } elseif ( is_array( $value ) || is_object( $value ) ) { + foreach ( $value as &$v ) { + $this->doReplacements( $v ); + } + unset( $v ); + } + } + + /** + * @dataProvider provideDiff + */ + public function testDiff( $params, $expect, $exceptionCode = false, $sysop = false ) { + $this->doReplacements( $params ); + + $params += [ + 'action' => 'compare', + ]; + + $user = $sysop + ? static::getTestSysop()->getUser() + : static::getTestUser()->getUser(); + if ( $exceptionCode ) { + try { + $this->doApiRequest( $params, null, false, $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( $this->apiExceptionHasCode( $ex, $exceptionCode ), + "Exception with code $exceptionCode" ); + } + } else { + $apiResult = $this->doApiRequest( $params, null, false, $user ); + $apiResult = $apiResult[0]; + $this->doReplacements( $expect ); + $this->assertEquals( $expect, $apiResult ); + } + } + + public static function provideDiff() { + return [ + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + 'Basic diff, titles' => [ + [ + 'fromtitle' => 'ApiComparePagesTest A', + 'totitle' => 'ApiComparePagesTest B', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA4}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB4}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 4
+
B 4
' . "\n", + ] + ], + ], + 'Basic diff, page IDs' => [ + [ + 'fromid' => '{{REPL:pageA}}', + 'toid' => '{{REPL:pageB}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA4}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB4}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 4
+
B 4
' . "\n", + ] + ], + ], + 'Basic diff, revision IDs' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revA3}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageA}}', + 'torevid' => '{{REPL:revA3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest A', + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 2
+
A 3
' . "\n", + ] + ], + ], + 'Basic diff, deleted revision ID as sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revC2}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => 0, + 'torevid' => '{{REPL:revC2}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest C', + 'toarchive' => true, + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 2
+
C 2
' . "\n", + ] + ], + false, true + ], + 'Basic diff, revdel as sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revB2}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB2}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'totexthidden' => true, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
A 2
+
B 2
' . "\n", + ] + ], + false, true + ], + 'Basic diff, text' => [ + [ + 'fromtext' => 'From text', + 'fromcontentmodel' => 'wikitext', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text {{subst:PAGENAME}}
' . "\n", + ] + ], + ], + 'Basic diff, text 2' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text {{subst:PAGENAME}}
' . "\n", + ] + ], + ], + 'Basic diff, guessed model' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text', + ], + [ + 'warnings' => [ + 'compare' => [ + 'warnings' => 'No content model could be determined, assuming wikitext.', + ], + ], + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text
' . "\n", + ] + ], + ], + 'Basic diff, text with title and PST' => [ + [ + 'fromtext' => 'From text', + 'totitle' => 'Test', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text Test
' . "\n", + ] + ], + ], + 'Basic diff, text with page ID and PST' => [ + [ + 'fromtext' => 'From text', + 'toid' => '{{REPL:pageB}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest B
' . "\n", + ] + ], + ], + 'Basic diff, text with revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revB2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest B
' . "\n", + ] + ], + ], + 'Basic diff, text with deleted revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revC2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
From text
+
To text ApiComparePagesTest C
' . "\n", + ] + ], + false, true + ], + 'Diff with all props' => [ + [ + 'fromrev' => '{{REPL:revB1}}', + 'torev' => '{{REPL:revB3}}', + 'totitle' => 'ApiComparePagesTest B', + 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size' + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageB}}', + 'fromrevid' => '{{REPL:revB1}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest B', + 'fromsize' => 3, + 'fromuser' => '{{REPL:creator}}', + 'fromuserid' => '{{REPL:creatorid}}', + 'fromcomment' => 'Test for ApiComparePagesTest: B 1', + 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'tosize' => 3, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'tosuppressed' => true, + 'next' => '{{REPL:revB4}}', + 'diffsize' => 391, + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
B 1
+
B 3
' . "\n", + ] + ], + ], + 'Diff with all props as sysop' => [ + [ + 'fromrev' => '{{REPL:revB2}}', + 'torev' => '{{REPL:revB3}}', + 'totitle' => 'ApiComparePagesTest B', + 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size' + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageB}}', + 'fromrevid' => '{{REPL:revB2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest B', + 'fromsize' => 3, + 'fromtexthidden' => true, + 'fromuserhidden' => true, + 'fromuser' => '{{REPL:creator}}', + 'fromuserid' => '{{REPL:creatorid}}', + 'fromcommenthidden' => true, + 'fromcomment' => 'Test for ApiComparePagesTest: B 2', + 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 2', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'tosize' => 3, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'tosuppressed' => true, + 'prev' => '{{REPL:revB1}}', + 'next' => '{{REPL:revB4}}', + 'diffsize' => 391, + 'body' => 'Line 1:' . "\n" + . 'Line 1:' . "\n" + . '−
B 2
+
B 3
' . "\n", + ] + ], + false, true + ], + 'Relative diff, cur' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'toid' => '{{REPL:pageA}}', + 'torevid' => '{{REPL:revA4}}', + ] + ], + ], + 'Relative diff, next' => [ + [ + 'fromrev' => '{{REPL:revE2}}', + 'torelative' => 'next', + 'prop' => 'ids|rel', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageE}}', + 'fromrevid' => '{{REPL:revE2}}', + 'toid' => '{{REPL:pageE}}', + 'torevid' => '{{REPL:revE3}}', + 'prev' => '{{REPL:revE1}}', + 'next' => '{{REPL:revE4}}', + ] + ], + ], + 'Relative diff, prev' => [ + [ + 'fromrev' => '{{REPL:revE3}}', + 'torelative' => 'prev', + 'prop' => 'ids|rel', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageE}}', + 'fromrevid' => '{{REPL:revE2}}', + 'toid' => '{{REPL:pageE}}', + 'torevid' => '{{REPL:revE3}}', + 'prev' => '{{REPL:revE1}}', + 'next' => '{{REPL:revE4}}', + ] + ], + ], + + 'Error, missing title' => [ + [ + 'fromtitle' => 'ApiComparePagesTest X', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'missingtitle', + ], + 'Error, invalid title' => [ + [ + 'fromtitle' => '', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'invalidtitle', + ], + 'Error, missing page ID' => [ + [ + 'fromid' => 8817900, + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchpageid', + ], + 'Error, page with missing revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest D', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, page with no revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest E', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, bad rev ID' => [ + [ + 'fromrev' => 8817900, + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, deleted revision ID, non-sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revC2}}', + ], + [], + 'nosuchrevid', + ], + 'Error, revision-deleted content' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revB2}}', + ], + [], + 'missingcontent', + ], + 'Error, text with no title and PST' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [], + 'compare-no-title', + ], + 'Error, Relative diff, no from revision' => [ + [ + 'fromtext' => 'Foo', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [], + 'compare-relative-to-nothing' + ], + 'Error, Relative diff, cur with no current revision' => [ + [ + 'fromrev' => '{{REPL:revE2}}', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [], + 'nosuchrevid' + ], + 'Error, Relative diff, next revdeleted' => [ + [ + 'fromrev' => '{{REPL:revB1}}', + 'torelative' => 'next', + 'prop' => 'ids', + ], + [], + 'missingcontent' + ], + 'Error, Relative diff, prev revdeleted' => [ + [ + 'fromrev' => '{{REPL:revB3}}', + 'torelative' => 'prev', + 'prop' => 'ids', + ], + [], + 'missingcontent' + ], + + // @codingStandardsIgnoreEnd + ]; + } +} -- 2.20.1