mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-11-13 18:37:07 +00:00
880f9755e0
Rename ThreadItem to ContentThreadItem, then create a new ThreadItem interface containing only the methods that we'll be able to implement using only the persistently stored data (no parsing), then create a DatabaseThreadItem. Do the same for CommentItem and HeadingItem. ThreadItemSet gets a similar treatment, but it's basically only for Phan's type checking. (This is sad.) Change-Id: I1633049befe8ec169753b82eb876459af1f63fe8
425 lines
14 KiB
PHP
425 lines
14 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Extension\DiscussionTools;
|
|
|
|
use ApiBase;
|
|
use ApiMain;
|
|
use DerivativeContext;
|
|
use DerivativeRequest;
|
|
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
|
|
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
|
|
use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
|
|
use MediaWiki\Logger\LoggerFactory;
|
|
use MediaWiki\MediaWikiServices;
|
|
use Title;
|
|
use Wikimedia\ParamValidator\ParamValidator;
|
|
use Wikimedia\ParamValidator\TypeDef\StringDef;
|
|
use Wikimedia\Parsoid\Utils\DOMCompat;
|
|
use Wikimedia\Parsoid\Utils\DOMUtils;
|
|
|
|
class ApiDiscussionToolsEdit extends ApiBase {
|
|
|
|
use ApiDiscussionToolsTrait;
|
|
use ApiParsoidTrait;
|
|
|
|
/** @var CommentParser */
|
|
private $commentParser;
|
|
|
|
/**
|
|
* @param ApiMain $main
|
|
* @param string $name
|
|
* @param CommentParser $commentParser
|
|
*/
|
|
public function __construct(
|
|
ApiMain $main,
|
|
$name,
|
|
CommentParser $commentParser
|
|
) {
|
|
parent::__construct( $main, $name );
|
|
$this->commentParser = $commentParser;
|
|
$this->setLogger( LoggerFactory::getInstance( 'DiscussionTools' ) );
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function execute() {
|
|
$params = $this->extractRequestParams();
|
|
$title = Title::newFromText( $params['page'] );
|
|
$result = null;
|
|
|
|
$dtConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'discussiontools' );
|
|
$autoSubscribe =
|
|
$dtConfig->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'discussiontoolsapi' &&
|
|
HookUtils::shouldAddAutoSubscription( $this->getUser(), $title );
|
|
$subscribableHeadingName = null;
|
|
$subscribableSectionTitle = '';
|
|
|
|
if ( !$title ) {
|
|
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
|
|
}
|
|
|
|
$this->getErrorFormatter()->setContextTitle( $title );
|
|
|
|
$session = null;
|
|
$usedFormTokensKey = 'DiscussionTools:usedFormTokens';
|
|
$formToken = $params['formtoken'];
|
|
if ( $formToken ) {
|
|
$session = $this->getContext()->getRequest()->getSession();
|
|
$usedFormTokens = $session->get( $usedFormTokensKey ) ?? [];
|
|
if ( in_array( $formToken, $usedFormTokens ) ) {
|
|
$this->dieWithError( [ 'apierror-discussiontools-formtoken-used' ] );
|
|
}
|
|
}
|
|
|
|
$this->requireOnlyOneParameter( $params, 'wikitext', 'html' );
|
|
if ( $params['paction'] === 'addtopic' ) {
|
|
$this->requireAtLeastOneParameter( $params, 'sectiontitle' );
|
|
}
|
|
|
|
// To determine if we need to add a signature,
|
|
// preview the comment without adding one and check if the result is signed properly.
|
|
$previewResult = $this->previewMessage(
|
|
$params['paction'] === 'addtopic' ? 'topic' : 'reply',
|
|
$title,
|
|
[
|
|
'wikitext' => $params['wikitext'],
|
|
'html' => $params['html'],
|
|
'sectiontitle' => $params['sectiontitle'],
|
|
],
|
|
$params
|
|
);
|
|
$previewResultHtml = $previewResult->getResultData( [ 'parse', 'text' ] );
|
|
$previewContainer = DOMCompat::getBody( DOMUtils::parseHTML( $previewResultHtml ) );
|
|
$previewThreadItemSet = $this->commentParser->parse( $previewContainer, $title->getTitleValue() );
|
|
if ( CommentUtils::isSingleCommentSignedBy(
|
|
$previewThreadItemSet, $this->getUser()->getName(), $previewContainer
|
|
) ) {
|
|
$signature = null;
|
|
} else {
|
|
$signature = $this->msg( 'discussiontools-signature-prefix' )->inContentLanguage()->text() . '~~~~';
|
|
}
|
|
|
|
switch ( $params['paction'] ) {
|
|
case 'addtopic':
|
|
$wikitext = $params['wikitext'];
|
|
$html = $params['html'];
|
|
|
|
if ( $wikitext !== null ) {
|
|
$wikitext = CommentUtils::htmlTrim( $wikitext );
|
|
if ( $signature !== null ) {
|
|
$wikitext .= $signature;
|
|
}
|
|
} else {
|
|
$doc = DOMUtils::parseHTML( '' );
|
|
$container = DOMUtils::parseHTMLToFragment( $doc, $html );
|
|
if ( $signature !== null ) {
|
|
CommentModifier::appendSignature( $container, $signature );
|
|
}
|
|
$html = DOMUtils::getFragmentInnerHTML( $container );
|
|
$wikitext = $this->transformHTML( $title, $html )[ 'body' ];
|
|
}
|
|
|
|
$mobileFormatParams = [];
|
|
// Boolean parameters must be omitted completely to be treated as false.
|
|
// Param is added by hook in MobileFrontend, so it may be unset.
|
|
if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
|
|
$mobileFormatParams['mobileformat'] = '1';
|
|
}
|
|
// As section=new this is append only so we don't need to
|
|
// worry about edit-conflict params such as oldid/baserevid/etag.
|
|
// Edit summary is also automatically generated when section=new
|
|
$context = new DerivativeContext( $this->getContext() );
|
|
$context->setRequest(
|
|
new DerivativeRequest(
|
|
$context->getRequest(),
|
|
[
|
|
'action' => 'visualeditoredit',
|
|
'paction' => 'save',
|
|
'page' => $params['page'],
|
|
'token' => $params['token'],
|
|
'wikitext' => $wikitext,
|
|
// A default is provided automatically by the Edit API
|
|
// for new sections when the summary is empty.
|
|
'summary' => $params['summary'],
|
|
'section' => 'new',
|
|
'sectiontitle' => $params['sectiontitle'],
|
|
'starttimestamp' => wfTimestampNow(),
|
|
'useskin' => $params['useskin'],
|
|
'watchlist' => $params['watchlist'],
|
|
'captchaid' => $params['captchaid'],
|
|
'captchaword' => $params['captchaword']
|
|
] + $mobileFormatParams,
|
|
/* was posted? */ true
|
|
)
|
|
);
|
|
$api = new ApiMain(
|
|
$context,
|
|
/* enable write? */ true
|
|
);
|
|
|
|
$api->execute();
|
|
|
|
$data = $api->getResult()->getResultData();
|
|
$result = $data['visualeditoredit'];
|
|
|
|
if ( $autoSubscribe && isset( $result['content'] ) ) {
|
|
// Determining the added topic's name directly is hard (we'd have to ensure we have the
|
|
// same timestamp, and replicate some CommentParser stuff). Just pull it out of the response.
|
|
$doc = DOMUtils::parseHTML( $result['content'] );
|
|
$container = DOMCompat::getBody( $doc );
|
|
$threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
|
|
$threads = $threadItemSet->getThreads();
|
|
if ( count( $threads ) ) {
|
|
$lastHeading = end( $threads );
|
|
$subscribableHeadingName = $lastHeading->getName();
|
|
$subscribableSectionTitle = $lastHeading->getLinkableTitle();
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
case 'addcomment':
|
|
$this->requireAtLeastOneParameter( $params, 'commentid', 'commentname' );
|
|
|
|
$commentId = $params['commentid'] ?? null;
|
|
$commentName = $params['commentname'] ?? null;
|
|
|
|
if ( !$title->exists() ) {
|
|
// The page does not exist, so the comment we're trying to reply to can't exist either.
|
|
if ( $commentId ) {
|
|
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
|
|
} else {
|
|
$this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
|
|
}
|
|
}
|
|
|
|
// Fetch the latest revision
|
|
$requestedRevision = $this->getLatestRevision( $title );
|
|
$response = $this->requestRestbasePageHtml( $requestedRevision );
|
|
|
|
$headers = $response['headers'];
|
|
$doc = DOMUtils::parseHTML( $response['body'] );
|
|
|
|
// Don't trust RESTBase to always give us the revision we requested,
|
|
// instead get the revision ID from the document and use that.
|
|
// Ported from ve.init.mw.ArticleTarget.prototype.parseMetadata
|
|
$docRevId = null;
|
|
$aboutDoc = $doc->documentElement->getAttribute( 'about' );
|
|
|
|
if ( $aboutDoc ) {
|
|
preg_match( '/revision\\/([0-9]+)$/', $aboutDoc, $docRevIdMatches );
|
|
if ( $docRevIdMatches ) {
|
|
$docRevId = (int)$docRevIdMatches[ 1 ];
|
|
}
|
|
}
|
|
|
|
if ( !$docRevId ) {
|
|
$this->dieWithError( 'apierror-visualeditor-docserver', 'docserver' );
|
|
}
|
|
|
|
if ( $docRevId !== $requestedRevision->getId() ) {
|
|
// TODO: If this never triggers, consider removing the check.
|
|
$this->getLogger()->warning(
|
|
"Requested revision {$requestedRevision->getId()} " .
|
|
"but received {$docRevId}."
|
|
);
|
|
}
|
|
|
|
$container = DOMCompat::getBody( $doc );
|
|
|
|
$threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
|
|
|
|
if ( $commentId ) {
|
|
$comment = $threadItemSet->findCommentById( $commentId );
|
|
|
|
if ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
|
|
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
|
|
}
|
|
|
|
} else {
|
|
$comments = $threadItemSet->findCommentsByName( $commentName );
|
|
$comment = $comments[ 0 ] ?? null;
|
|
|
|
if ( count( $comments ) > 1 ) {
|
|
$this->dieWithError( [ 'apierror-discussiontools-commentname-ambiguous', $commentName ] );
|
|
} elseif ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
|
|
$this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
|
|
}
|
|
}
|
|
|
|
if ( $params['wikitext'] !== null ) {
|
|
CommentModifier::addWikitextReply( $comment, $params['wikitext'], $signature );
|
|
} else {
|
|
CommentModifier::addHtmlReply( $comment, $params['html'], $signature );
|
|
}
|
|
|
|
if ( isset( $params['summary'] ) ) {
|
|
$summary = $params['summary'];
|
|
} else {
|
|
$sectionTitle = $comment->getHeading()->getLinkableTitle();
|
|
$summary = ( $sectionTitle ? '/* ' . $sectionTitle . ' */ ' : '' ) .
|
|
$this->msg( 'discussiontools-defaultsummary-reply' )->inContentLanguage()->text();
|
|
}
|
|
|
|
if ( $autoSubscribe ) {
|
|
$heading = $comment->getSubscribableHeading();
|
|
if ( $heading ) {
|
|
$subscribableHeadingName = $heading->getName();
|
|
$subscribableSectionTitle = $heading->getLinkableTitle();
|
|
}
|
|
}
|
|
|
|
$context = new DerivativeContext( $this->getContext() );
|
|
$context->setRequest(
|
|
new DerivativeRequest(
|
|
$context->getRequest(),
|
|
[
|
|
'action' => 'visualeditoredit',
|
|
'paction' => 'save',
|
|
'page' => $params['page'],
|
|
'token' => $params['token'],
|
|
'oldid' => $docRevId,
|
|
'html' => DOMCompat::getOuterHTML( $doc->documentElement ),
|
|
'summary' => $summary,
|
|
'baserevid' => $docRevId,
|
|
'starttimestamp' => wfTimestampNow(),
|
|
'etag' => $headers['etag'],
|
|
'useskin' => $params['useskin'],
|
|
'watchlist' => $params['watchlist'],
|
|
'captchaid' => $params['captchaid'],
|
|
'captchaword' => $params['captchaword']
|
|
],
|
|
/* was posted? */ true
|
|
)
|
|
);
|
|
$api = new ApiMain(
|
|
$context,
|
|
/* enable write? */ true
|
|
);
|
|
|
|
$api->execute();
|
|
|
|
// TODO: Tags are only added by 'dttags' existing on the original request
|
|
// context (see Hook::onRecentChangeSave). What tags (if any) should be
|
|
// added in this API?
|
|
|
|
$data = $api->getResult()->getResultData();
|
|
$result = $data['visualeditoredit'];
|
|
break;
|
|
}
|
|
|
|
if ( !isset( $result['newrevid'] ) && isset( $result['result'] ) && $result['result'] === 'success' ) {
|
|
// No new revision, so no changes were made to the page (null edit).
|
|
// Comment was not actually saved, so for this API, that's an error.
|
|
// This is probably because changes were inside a transclusion's HTML?
|
|
$this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-comment-not-saved' );
|
|
}
|
|
|
|
if ( $autoSubscribe && $subscribableHeadingName ) {
|
|
$subscriptionStore = MediaWikiServices::getInstance()->getService( 'DiscussionTools.SubscriptionStore' );
|
|
$subsTitle = $title->createFragmentTarget( $subscribableSectionTitle );
|
|
$subscriptionStore->addAutoSubscriptionForUser( $this->getUser(), $subsTitle, $subscribableHeadingName );
|
|
}
|
|
|
|
// Check the post was successful (could have been blocked by ConfirmEdit) before
|
|
// marking the form token as used.
|
|
if ( $formToken && isset( $result['result'] ) && $result['result'] === 'success' ) {
|
|
$usedFormTokens[] = $formToken;
|
|
// Set an arbitrary limit of the number of form tokens to
|
|
// store to prevent session storage from becoming full.
|
|
// It is unlikely that form tokens other than the few most
|
|
// recently used will be needed.
|
|
while ( count( $usedFormTokens ) > 50 ) {
|
|
// Discard the oldest tokens first
|
|
array_shift( $usedFormTokens );
|
|
}
|
|
$session->set( $usedFormTokensKey, $usedFormTokens );
|
|
}
|
|
|
|
$this->getResult()->addValue( null, $this->getModuleName(), $result );
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function getAllowedParams() {
|
|
return [
|
|
'paction' => [
|
|
ParamValidator::PARAM_REQUIRED => true,
|
|
ParamValidator::PARAM_TYPE => [
|
|
'addcomment',
|
|
'addtopic',
|
|
],
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-paction',
|
|
ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
|
|
],
|
|
'page' => [
|
|
ParamValidator::PARAM_REQUIRED => true,
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
|
|
],
|
|
'token' => [
|
|
ParamValidator::PARAM_REQUIRED => true,
|
|
],
|
|
'formtoken' => [
|
|
ParamValidator::PARAM_TYPE => 'string',
|
|
StringDef::PARAM_MAX_CHARS => 16,
|
|
],
|
|
'commentname' => null,
|
|
'commentid' => null,
|
|
'wikitext' => [
|
|
ParamValidator::PARAM_TYPE => 'text',
|
|
ParamValidator::PARAM_DEFAULT => null,
|
|
],
|
|
'html' => [
|
|
ParamValidator::PARAM_TYPE => 'text',
|
|
ParamValidator::PARAM_DEFAULT => null,
|
|
],
|
|
'summary' => [
|
|
ParamValidator::PARAM_TYPE => 'string',
|
|
ParamValidator::PARAM_DEFAULT => null,
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-summary',
|
|
],
|
|
'sectiontitle' => [
|
|
ParamValidator::PARAM_TYPE => 'string',
|
|
],
|
|
'useskin' => [
|
|
ParamValidator::PARAM_TYPE => array_keys(
|
|
MediaWikiServices::getInstance()->getSkinFactory()->getInstalledSkins()
|
|
),
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
|
|
],
|
|
'watchlist' => [
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-watchlist',
|
|
],
|
|
'captchaid' => [
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaid',
|
|
],
|
|
'captchaword' => [
|
|
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaword',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function needsToken() {
|
|
return 'csrf';
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function isInternal() {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function isWriteMode() {
|
|
return true;
|
|
}
|
|
}
|