Merge "Handle reply/topic preview entirely server-side"

This commit is contained in:
jenkins-bot 2022-03-02 14:13:59 +00:00 committed by Gerrit Code Review
commit 094b77b4bb
10 changed files with 237 additions and 110 deletions

View file

@ -50,8 +50,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"
]
},
{
@ -299,8 +298,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",
@ -430,6 +427,9 @@
"discussiontoolspageinfo": {
"class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsPageInfo"
},
"discussiontoolspreview": {
"class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsPreview"
},
"discussiontoolssubscribe": {
"class": "MediaWiki\\Extension\\DiscussionTools\\ApiDiscussionToolsSubscribe",
"services": [

View file

@ -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",

View file

@ -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}}",

View file

@ -0,0 +1,94 @@
<?php
namespace MediaWiki\Extension\DiscussionTools;
use ApiBase;
use ApiMain;
use ApiParsoidTrait;
use Title;
use Wikimedia\ParamValidator\ParamValidator;
class ApiDiscussionToolsPreview extends ApiBase {
use ApiDiscussionToolsTrait;
use ApiParsoidTrait;
/**
* @inheritDoc
*/
public function __construct( ApiMain $main, string $name ) {
parent::__construct( $main, $name );
}
/**
* @inheritDoc
*/
public function execute() {
$params = $this->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 <span> markup to ensure accurate previews.
$signature = preg_replace_callback( '/^( *)(.+)$/', static function ( $matches ) {
list( , $leadingSpaces, $sig ) = $matches;
return $leadingSpaces . '<span style="opacity: 0.6;">' . $sig . '</span>';
}, $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;
}
}

View file

@ -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();
}

View file

@ -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
*

View file

@ -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' )
};

View file

@ -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 <span> markup to ensure accurate previews.
signature = signature.replace( /^( *)(.+)$/, function ( _, leadingSpaces, sig ) {
return leadingSpaces + '<span style="opacity: 0.6;">' + sig + '</span>';
} );
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 );

View file

@ -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
};

View file

@ -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' );