diff --git a/extension.json b/extension.json index 58416a89e..4997213fe 100644 --- a/extension.json +++ b/extension.json @@ -48,8 +48,7 @@ "discussiontools-replywidget-feedback-link", "discussiontools-replywidget-feedback-link-newtopic", "discussiontools-replywidget-mention-prefix", - "discussiontools-replywidget-mention-suffix", - "discussiontools-signature-prefix" + "discussiontools-replywidget-mention-suffix" ] }, { @@ -295,8 +294,6 @@ "cases/modified.json", "cases/reply.json", "cases/unwrap.json", - "cases/isWikitextSigned.json", - "cases/isHtmlSigned.json", "cases/linearWalk.json", "cases/sanitize-wikitext-linebreaks.json", "cases/timestamp-regex.json", @@ -422,6 +419,9 @@ "discussiontoolspageinfo": { "class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsPageInfo" }, + "discussiontoolspreview": { + "class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsPreview" + }, "discussiontoolssubscribe": { "class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsSubscribe", "services": [ diff --git a/i18n/api/en.json b/i18n/api/en.json index 30a6b6b30..f86045a5d 100644 --- a/i18n/api/en.json +++ b/i18n/api/en.json @@ -22,6 +22,9 @@ "apihelp-discussiontoolsgetsubscriptions-summary": "Get the subscription statuses of given topics.", "apihelp-discussiontoolspageinfo-param-oldid": "The revision number to use (defaults to latest revision).", "apihelp-discussiontoolspageinfo-summary": "Returns metadata required to initialize the discussion tools.", + "apihelp-discussiontoolspreview-param-type": "Type of message to preview", + "apihelp-discussiontoolspreview-param-wikitext": "Content to preview, as wikitext.", + "apihelp-discussiontoolspreview-summary": "Preview a message on a discussion page.", "apihelp-discussiontoolssubscribe-param-commentname": "Name of the topic to subscribe to (or unsubscribe from)", "apihelp-discussiontoolssubscribe-param-page": "A page on which the topic appears", "apihelp-discussiontoolssubscribe-param-subscribe": "True to subscribe, false to unsubscribe", diff --git a/i18n/api/qqq.json b/i18n/api/qqq.json index a1df8861c..d50b1e04c 100644 --- a/i18n/api/qqq.json +++ b/i18n/api/qqq.json @@ -24,6 +24,9 @@ "apihelp-discussiontoolsgetsubscriptions-summary": "{{doc-apihelp-summary|discussiontoolsgetsubscriptions}}", "apihelp-discussiontoolspageinfo-param-oldid": "{{doc-apihelp-param|discussiontoolspageinfo|oldid}}", "apihelp-discussiontoolspageinfo-summary": "{{doc-apihelp-summary|discussiontoolspageinfo}}", + "apihelp-discussiontoolspreview-param-type": "{{doc-apihelp-param|discussiontoolspreview|type}}", + "apihelp-discussiontoolspreview-param-wikitext": "{{doc-apihelp-param|discussiontoolspreview|wikitext}}", + "apihelp-discussiontoolspreview-summary": "{{doc-apihelp-summary|discussiontoolspreview}}", "apihelp-discussiontoolssubscribe-param-commentname": "{{doc-apihelp-param|discussiontoolssubscribe|commentname}}", "apihelp-discussiontoolssubscribe-param-page": "{{doc-apihelp-param|discussiontoolssubscribe|page}}", "apihelp-discussiontoolssubscribe-param-subscribe": "{{doc-apihelp-param|discussiontoolssubscribe|subscribe}}", diff --git a/includes/ApiDiscussionToolsPreview.php b/includes/ApiDiscussionToolsPreview.php new file mode 100644 index 000000000..3a8f49f26 --- /dev/null +++ b/includes/ApiDiscussionToolsPreview.php @@ -0,0 +1,94 @@ +extractRequestParams(); + $title = Title::newFromText( $params['page'] ); + + if ( !$title ) { + $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] ); + } + if ( $params['type'] === 'topic' ) { + $this->requireAtLeastOneParameter( $params, 'sectiontitle' ); + } + + // Add signature if missing + $signature = null; + if ( !CommentModifier::isWikitextSigned( $params['wikitext'] ) ) { + $signature = $this->msg( 'discussiontools-signature-prefix' )->inContentLanguage()->text() . '~~~~'; + // Drop opacity of signature in preview to make message body preview clearer. + // Extract any leading spaces outside the markup to ensure accurate previews. + $signature = preg_replace_callback( '/^( *)(.+)$/', static function ( $matches ) { + list( , $leadingSpaces, $sig ) = $matches; + return $leadingSpaces . '' . $sig . ''; + }, $signature ); + } + + $result = $this->previewMessage( [ + 'type' => $params['type'], + 'title' => $title, + 'wikitext' => $params['wikitext'], + 'sectiontitle' => $params['sectiontitle'], + 'signature' => $signature, + ] ); + + $this->getResult()->addValue( null, $this->getModuleName(), $result->serializeForApiResult() ); + } + + /** + * @inheritDoc + */ + public function getAllowedParams() { + return [ + 'type' => [ + ParamValidator::PARAM_REQUIRED => true, + ParamValidator::PARAM_TYPE => [ + 'reply', + 'topic', + ], + ApiBase::PARAM_HELP_MSG_PER_VALUE => [], + ], + 'page' => [ + ParamValidator::PARAM_REQUIRED => true, + ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page', + ], + 'wikitext' => [ + ParamValidator::PARAM_REQUIRED => true, + ParamValidator::PARAM_TYPE => 'text', + ], + 'sectiontitle' => [ + ParamValidator::PARAM_TYPE => 'string', + ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-sectiontitle', + ], + ]; + } + + /** + * @inheritDoc + */ + public function isInternal() { + return true; + } +} diff --git a/includes/ApiDiscussionToolsTrait.php b/includes/ApiDiscussionToolsTrait.php index f7ae636d5..359d16a67 100644 --- a/includes/ApiDiscussionToolsTrait.php +++ b/includes/ApiDiscussionToolsTrait.php @@ -2,12 +2,20 @@ namespace MediaWiki\Extension\DiscussionTools; +use ApiMain; +use ApiResult; +use DerivativeContext; +use DerivativeRequest; +use IContextSource; use MediaWiki\MediaWikiServices; use MediaWiki\Revision\RevisionRecord; use Title; use Wikimedia\Parsoid\Utils\DOMCompat; use Wikimedia\Parsoid\Utils\DOMUtils; +/** + * Random methods we want to share between API modules. + */ trait ApiDiscussionToolsTrait { /** * @param RevisionRecord $revision @@ -29,9 +37,99 @@ trait ApiDiscussionToolsTrait { return $parser->parse( $container, $title ); } + /** + * Given parameters describing a reply or new topic, transform them into wikitext using Parsoid, + * then preview the wikitext using the legacy parser. + * + * @param array $params Associative array with the following keys: + * - `type` (string) 'topic' or 'reply' + * - `title` (Title) Context title for wikitext transformations + * - `wikitext` (string) Content of the message + * - `sectiontitle` (string) Content of the title, when `type` is 'topic' + * - `signature` (string|null) Wikitext signature to add to the message + * @return ApiResult action=parse API result + */ + protected function previewMessage( array $params ): ApiResult { + $wikitext = $params['wikitext']; + $title = $params['title']; + $signature = $params['signature'] ?? null; + + switch ( $params['type'] ) { + case 'topic': + $wikitext = CommentUtils::htmlTrim( $wikitext ); + if ( $signature !== null ) { + $wikitext .= $signature; + } + + if ( $params['sectiontitle'] ) { + $wikitext = "== " . $params['sectiontitle'] . " ==\n" . $wikitext; + } + + break; + + case 'reply': + $doc = DOMUtils::parseHTML( '' ); + + $container = CommentModifier::prepareWikitextReply( $doc, $wikitext ); + + if ( $signature !== null ) { + CommentModifier::appendSignature( $container, $signature ); + } + $list = CommentModifier::transferReply( $container ); + $html = DOMCompat::getOuterHTML( $list ); + + $wikitext = $this->transformHTML( $title, $html )[ 'body' ]; + + break; + } + + $apiParams = [ + 'action' => 'parse', + 'title' => $title->getPrefixedText(), + 'text' => $wikitext, + 'pst' => '1', + 'preview' => '1', + 'disableeditsection' => '1', + 'prop' => 'text|modules|jsconfigvars', + ]; + + $context = new DerivativeContext( $this->getContext() ); + $context->setRequest( + new DerivativeRequest( + $context->getRequest(), + $apiParams, + /* was posted? */ true + ) + ); + $api = new ApiMain( + $context, + /* enable write? */ false + ); + + $api->execute(); + return $api->getResult(); + } + /** * @param RevisionRecord $revision * @return array */ abstract protected function requestRestbasePageHtml( RevisionRecord $revision ): array; + + /** + * @param Title $title + * @param string $html + * @param int|null $oldid + * @param string|null $etag + * @return array + */ + abstract protected function transformHTML( + Title $title, string $html, int $oldid = null, string $etag = null + ): array; + + /** + * @return IContextSource + */ + abstract public function getContext(); + } diff --git a/includes/CommentModifier.php b/includes/CommentModifier.php index eab1ce196..cba357ce1 100644 --- a/includes/CommentModifier.php +++ b/includes/CommentModifier.php @@ -541,6 +541,31 @@ class CommentModifier { } } + /** + * Transfer comment DOM nodes into a list node, as if adding a reply, but without requiring a + * ThreadItem. + * + * @param DocumentFragment $container Container of comment DOM nodes + * @return Element $node List node + */ + public static function transferReply( DocumentFragment $container ): Element { + $services = MediaWikiServices::getInstance(); + $dtConfig = $services->getConfigFactory()->makeConfig( 'discussiontools' ); + $replyIndentation = $dtConfig->get( 'DiscussionToolsReplyIndentation' ); + + $doc = $container->ownerDocument; + + // Like addReply(), but we make our own list + $list = $doc->createElement( $replyIndentation === 'invisible' ? 'dl' : 'ul' ); + while ( $container->childNodes->length ) { + $item = $doc->createElement( $replyIndentation === 'invisible' ? 'dd' : 'li' ); + self::whitespaceParsoidHack( $item ); + $item->appendChild( $container->firstChild ); + $list->appendChild( $item ); + } + return $list; + } + /** * Create a container of comment DOM nodes from wikitext * diff --git a/modules/dt.init.js b/modules/dt.init.js index 80df07b59..6b7a3310c 100644 --- a/modules/dt.init.js +++ b/modules/dt.init.js @@ -1,5 +1,4 @@ var controller = require( './controller.js' ), - config = require( './config.json' ), uri = new mw.Uri(); /** @@ -74,6 +73,5 @@ module.exports = { HeadingItem: require( './HeadingItem.js' ), CommentItem: require( './CommentItem.js' ), utils: require( './utils.js' ), - logger: require( './logger.js' ), - config: config + logger: require( './logger.js' ) }; diff --git a/modules/dt.ui.ReplyWidget.js b/modules/dt.ui.ReplyWidget.js index 2bb7f348a..f754dd8da 100644 --- a/modules/dt.ui.ReplyWidget.js +++ b/modules/dt.ui.ReplyWidget.js @@ -1,8 +1,6 @@ var controller = require( 'ext.discussionTools.init' ).controller, - modifier = require( 'ext.discussionTools.init' ).modifier, utils = require( 'ext.discussionTools.init' ).utils, logger = require( 'ext.discussionTools.init' ).logger, - dtConf = require( 'ext.discussionTools.init' ).config, ModeTabSelectWidget = require( './ModeTabSelectWidget.js' ), ModeTabOptionWidget = require( './ModeTabOptionWidget.js' ), licenseMessages = require( './licenseMessages.json' ), @@ -708,7 +706,6 @@ ReplyWidget.prototype.preparePreview = function ( wikitext ) { return $.Deferred().resolve().promise(); } - var indent = dtConf.replyIndentation === 'invisible' ? ':' : '*'; wikitext = wikitext !== undefined ? wikitext : this.getValue(); wikitext = utils.htmlTrim( wikitext ); var title = this.isNewTopic && this.commentController.sectionTitle.getValue(); @@ -728,40 +725,22 @@ ReplyWidget.prototype.preparePreview = function ( wikitext ) { if ( !wikitext ) { parsePromise = $.Deferred().resolve( null ).promise(); } else { - wikitext = this.commentController.doIndentReplacements( wikitext, indent ); - - if ( !modifier.isWikitextSigned( wikitext ) ) { - // Add signature. - var signature = mw.msg( 'discussiontools-signature-prefix' ) + '~~~~'; - // Drop opacity of signature in preview to make message body preview clearer. - // Extract any leading spaces outside the markup to ensure accurate previews. - signature = signature.replace( /^( *)(.+)$/, function ( _, leadingSpaces, sig ) { - return leadingSpaces + '' + sig + ''; - } ); - wikitext += signature; - } - if ( title ) { - wikitext = '== ' + title + ' ==\n' + wikitext; - } this.previewRequest = parsePromise = controller.getApi().post( { - action: 'parse', - text: wikitext, - pst: true, - preview: true, - disableeditsection: true, - prop: [ 'text', 'modules', 'jsconfigvars' ], - title: this.pageName + action: 'discussiontoolspreview', + type: this.isNewTopic ? 'topic' : 'reply', + page: this.pageName, + wikitext: wikitext, + sectiontitle: title } ); } - // TODO: Add list context return parsePromise.then( function ( response ) { - widget.$preview.html( response ? response.parse.text : '' ); + widget.$preview.html( response ? response.discussiontoolspreview.parse.text : '' ); if ( response ) { - mw.config.set( response.parse.jsconfigvars ); - mw.loader.load( response.parse.modulestyles ); - mw.loader.load( response.parse.modules ); + mw.config.set( response.discussiontoolspreview.parse.jsconfigvars ); + mw.loader.load( response.discussiontoolspreview.parse.modulestyles ); + mw.loader.load( response.discussiontoolspreview.parse.modules ); } mw.hook( 'wikipage.content' ).fire( widget.$preview ); diff --git a/modules/modifier.js b/modules/modifier.js index 0a8af29c7..11ee7de97 100644 --- a/modules/modifier.js +++ b/modules/modifier.js @@ -363,57 +363,11 @@ function addSiblingListItem( previousItem ) { return listItem; } -/** - * Check whether wikitext contains a user signature. - * - * @param {string} wikitext - * @return {boolean} - */ -function isWikitextSigned( wikitext ) { - wikitext = utils.htmlTrim( wikitext ); - // Contains ~~~~ (four tildes), but not ~~~~~ (five tildes), at the end. - return /([^~]|^)~~~~$/.test( wikitext ); -} - -/** - * Check whether HTML node contains a user signature. - * - * @param {HTMLElement} container - * @return {boolean} - */ -function isHtmlSigned( container ) { - // Good enough?… - var matches = container.querySelectorAll( 'span[typeof="mw:Transclusion"][data-mw*="~~~~"]' ); - if ( matches.length === 0 ) { - return false; - } - var lastSig = matches[ matches.length - 1 ]; - // Signature must be at the end of the comment - there must be no sibling following this node, or its parents - var node = lastSig; - while ( node ) { - // Skip over whitespace nodes - while ( - node.nextSibling && - node.nextSibling.nodeType === Node.TEXT_NODE && - utils.htmlTrim( node.nextSibling.textContent ) === '' - ) { - node = node.nextSibling; - } - if ( node.nextSibling ) { - return false; - } - node = node.parentNode; - } - return true; -} - module.exports = { addReplyLink: addReplyLink, addListItem: addListItem, removeAddedListItem: removeAddedListItem, addSiblingListItem: addSiblingListItem, unwrapList: unwrapList, - isWikitextSigned: isWikitextSigned, - isHtmlSigned: isHtmlSigned, sanitizeWikitextLinebreaks: sanitizeWikitextLinebreaks }; diff --git a/tests/qunit/modifier.test.js b/tests/qunit/modifier.test.js index 7f44739c0..6bb421d83 100644 --- a/tests/qunit/modifier.test.js +++ b/tests/qunit/modifier.test.js @@ -122,33 +122,6 @@ QUnit.test( '#unwrapList', function ( assert ) { } ); } ); -QUnit.test( 'isWikitextSigned', function ( assert ) { - var cases = require( '../cases/isWikitextSigned.json' ); - - cases.forEach( function ( caseItem ) { - assert.strictEqual( - modifier.isWikitextSigned( caseItem.wikitext ), - caseItem.expected, - caseItem.msg - ); - } ); -} ); - -QUnit.test( 'isHtmlSigned', function ( assert ) { - var cases = require( '../cases/isHtmlSigned.json' ); - - cases.forEach( function ( caseItem ) { - var container = document.createElement( 'div' ); - container.innerHTML = caseItem.html; - - assert.strictEqual( - modifier.isHtmlSigned( container ), - caseItem.expected, - caseItem.msg - ); - } ); -} ); - QUnit.test( 'sanitizeWikitextLinebreaks', function ( assert ) { var cases = require( '../cases/sanitize-wikitext-linebreaks.json' );