2020-09-16 12:07:27 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
namespace MediaWiki\Extension\DiscussionTools;
|
|
|
|
|
2021-04-19 19:14:07 +00:00
|
|
|
use DOMDocument;
|
2020-09-16 12:07:27 +00:00
|
|
|
use DOMElement;
|
2021-01-06 17:06:27 +00:00
|
|
|
use Language;
|
2021-04-08 12:30:28 +00:00
|
|
|
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
|
2021-01-29 18:31:27 +00:00
|
|
|
use MediaWiki\MediaWikiServices;
|
2021-04-19 19:14:07 +00:00
|
|
|
use MediaWiki\User\UserIdentity;
|
2021-01-29 18:31:27 +00:00
|
|
|
use MWExceptionHandler;
|
|
|
|
use Throwable;
|
|
|
|
use WebRequest;
|
2021-04-19 19:14:07 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMCompat;
|
2020-11-16 15:50:46 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMUtils;
|
2021-02-13 19:01:58 +00:00
|
|
|
use Wikimedia\Parsoid\Wt2Html\XMLSerializer;
|
2020-09-16 12:07:27 +00:00
|
|
|
|
2020-11-16 15:50:46 +00:00
|
|
|
class CommentFormatter {
|
2021-02-17 17:16:17 +00:00
|
|
|
// List of features which, when enabled, cause the comment formatter to run
|
|
|
|
public const USE_WITH_FEATURES = [
|
2021-04-08 12:30:28 +00:00
|
|
|
HookUtils::REPLYTOOL,
|
|
|
|
HookUtils::TOPICSUBSCRIPTION,
|
2021-02-17 17:16:17 +00:00
|
|
|
];
|
|
|
|
|
2021-04-19 18:34:55 +00:00
|
|
|
protected const MARKER_COMMENT = '<!-- DiscussionTools addDiscussionTools called -->';
|
|
|
|
// Compatibility with old cached content
|
2021-01-29 18:31:27 +00:00
|
|
|
protected const REPLY_LINKS_COMMENT = '<!-- DiscussionTools addReplyLinks called -->';
|
2020-09-16 12:07:27 +00:00
|
|
|
|
2021-01-08 22:19:20 +00:00
|
|
|
/**
|
|
|
|
* Get a comment parser object for a DOM element
|
|
|
|
*
|
|
|
|
* This method exists so it can mocked in tests.
|
|
|
|
*
|
|
|
|
* @param DOMElement $container
|
|
|
|
* @return CommentParser
|
|
|
|
*/
|
|
|
|
protected static function getParser( DOMElement $container ) : CommentParser {
|
|
|
|
return CommentParser::newFromGlobalState( $container );
|
|
|
|
}
|
|
|
|
|
2021-01-29 18:31:27 +00:00
|
|
|
/**
|
2021-04-19 18:34:55 +00:00
|
|
|
* Add discussion tools to some HTML
|
2021-01-29 18:31:27 +00:00
|
|
|
*
|
|
|
|
* @param string &$text Parser text output
|
|
|
|
*/
|
2021-04-15 21:09:55 +00:00
|
|
|
public static function addDiscussionTools( string &$text ) : void {
|
2021-01-29 18:31:27 +00:00
|
|
|
$start = microtime( true );
|
|
|
|
|
2021-04-19 18:34:55 +00:00
|
|
|
// Never add tools twice.
|
|
|
|
// This is required because we try again to add tools to cached content
|
2021-01-29 18:31:27 +00:00
|
|
|
// to support query string or cookie enabling
|
2021-04-19 18:34:55 +00:00
|
|
|
if ( strpos( $text, static::MARKER_COMMENT ) !== false ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// Compatibility with old cached content
|
2021-01-29 18:31:27 +00:00
|
|
|
if ( strpos( $text, static::REPLY_LINKS_COMMENT ) !== false ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-04-19 18:34:55 +00:00
|
|
|
$text = $text . "\n" . static::MARKER_COMMENT;
|
2021-01-29 18:31:27 +00:00
|
|
|
|
|
|
|
try {
|
2021-04-15 21:09:55 +00:00
|
|
|
$newText = static::addDiscussionToolsInternal( $text );
|
2021-01-29 18:31:27 +00:00
|
|
|
} catch ( Throwable $e ) {
|
|
|
|
// Catch errors, so that they don't cause the entire page to not display.
|
|
|
|
// Log it and add the request ID in a comment to make it easier to find in the logs.
|
|
|
|
MWExceptionHandler::logException( $e );
|
|
|
|
|
|
|
|
$requestId = htmlspecialchars( WebRequest::getRequestId() );
|
2021-04-19 18:34:55 +00:00
|
|
|
$info = "<!-- [$requestId] DiscussionTools could not process this page -->";
|
2021-01-29 18:31:27 +00:00
|
|
|
$text .= "\n" . $info;
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
$text = $newText;
|
|
|
|
$duration = microtime( true ) - $start;
|
|
|
|
|
|
|
|
$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
|
|
|
|
$stats->timing( 'discussiontools.addReplyLinks', $duration * 1000 );
|
|
|
|
}
|
|
|
|
|
2020-11-16 15:50:46 +00:00
|
|
|
/**
|
2021-04-19 18:34:55 +00:00
|
|
|
* Add discussion tools to some HTML
|
2020-11-16 15:50:46 +00:00
|
|
|
*
|
|
|
|
* @param string $html HTML
|
2021-04-19 18:34:55 +00:00
|
|
|
* @return string HTML with discussion tools
|
2020-11-16 15:50:46 +00:00
|
|
|
*/
|
2021-04-15 21:09:55 +00:00
|
|
|
protected static function addDiscussionToolsInternal( string $html ) : string {
|
2020-10-21 15:52:04 +00:00
|
|
|
// The output of this method can end up in the HTTP cache (Varnish). Avoid changing it;
|
|
|
|
// and when doing so, ensure that frontend code can handle both the old and new outputs.
|
|
|
|
// See controller#init in JS.
|
|
|
|
|
2020-11-16 15:50:46 +00:00
|
|
|
$doc = DOMUtils::parseHTML( $html );
|
|
|
|
$doc->preserveWhiteSpace = false;
|
|
|
|
|
|
|
|
$container = $doc->getElementsByTagName( 'body' )->item( 0 );
|
2020-09-16 12:07:27 +00:00
|
|
|
if ( !( $container instanceof DOMElement ) ) {
|
2020-11-16 15:50:46 +00:00
|
|
|
return $html;
|
2020-09-16 12:07:27 +00:00
|
|
|
}
|
|
|
|
|
2021-01-08 22:19:20 +00:00
|
|
|
$parser = static::getParser( $container );
|
2020-09-16 12:07:27 +00:00
|
|
|
$threadItems = $parser->getThreadItems();
|
|
|
|
|
|
|
|
foreach ( $threadItems as $threadItem ) {
|
|
|
|
// TODO: Consider not attaching JSON data to the DOM.
|
|
|
|
// Create a dummy node to attach data to.
|
|
|
|
if ( $threadItem instanceof HeadingItem && $threadItem->isPlaceholderHeading() ) {
|
|
|
|
$node = $doc->createElement( 'span' );
|
2021-02-22 22:02:41 +00:00
|
|
|
$container->insertBefore( $node, $container->firstChild );
|
2020-09-16 12:07:27 +00:00
|
|
|
$threadItem->setRange( new ImmutableRange( $node, 0, $node, 0 ) );
|
|
|
|
}
|
|
|
|
|
2020-10-27 12:18:36 +00:00
|
|
|
// And start and end markers to range
|
|
|
|
$id = $threadItem->getId();
|
|
|
|
$range = $threadItem->getRange();
|
|
|
|
$startMarker = $doc->createElement( 'span' );
|
2021-03-10 17:25:52 +00:00
|
|
|
$startMarker->setAttribute( 'data-mw-comment-start', '' );
|
|
|
|
$startMarker->setAttribute( 'id', $id );
|
2020-10-27 12:18:36 +00:00
|
|
|
$endMarker = $doc->createElement( 'span' );
|
|
|
|
$endMarker->setAttribute( 'data-mw-comment-end', $id );
|
2020-12-14 16:57:51 +00:00
|
|
|
|
|
|
|
// Extend the range if the start or end is inside an element which can't have element children.
|
|
|
|
// (There may be other problematic elements... but this seems like a good start.)
|
|
|
|
if ( CommentUtils::cantHaveElementChildren( $range->startContainer ) ) {
|
|
|
|
$range = $range->setStart(
|
|
|
|
$range->startContainer->parentNode,
|
|
|
|
CommentUtils::childIndexOf( $range->startContainer )
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if ( CommentUtils::cantHaveElementChildren( $range->endContainer ) ) {
|
|
|
|
$range = $range->setEnd(
|
|
|
|
$range->endContainer->parentNode,
|
|
|
|
CommentUtils::childIndexOf( $range->endContainer ) + 1
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-10-27 12:18:36 +00:00
|
|
|
$range->setStart( $range->endContainer, $range->endOffset )->insertNode( $endMarker );
|
|
|
|
$range->insertNode( $startMarker );
|
2020-09-16 12:07:27 +00:00
|
|
|
|
|
|
|
$itemData = $threadItem->jsonSerialize();
|
|
|
|
$itemJSON = json_encode( $itemData );
|
|
|
|
|
|
|
|
if ( $threadItem instanceof HeadingItem ) {
|
|
|
|
$threadItem->getRange()->endContainer->setAttribute( 'data-mw-comment', $itemJSON );
|
2021-04-15 21:09:55 +00:00
|
|
|
if ( !$threadItem->isPlaceholderHeading() && $threadItem->getHeadingLevel() === 2 ) {
|
2021-02-17 22:34:02 +00:00
|
|
|
$headingNode = CommentUtils::closestElement( $threadItem->getRange()->endContainer, [ 'h2' ] );
|
|
|
|
|
|
|
|
if ( $headingNode ) {
|
2021-04-19 21:47:53 +00:00
|
|
|
$existingClass = $headingNode->getAttribute( 'class' );
|
|
|
|
$headingNode->setAttribute(
|
|
|
|
'class',
|
|
|
|
( $existingClass ? $existingClass . ' ' : '' ) . 'ext-discussiontools-section'
|
|
|
|
);
|
2021-02-17 22:34:02 +00:00
|
|
|
|
2021-04-19 19:14:07 +00:00
|
|
|
// Replaced in ::postprocessTopicSubscription() as the icon depends on user state
|
2021-02-17 22:34:02 +00:00
|
|
|
$subscribe = $doc->createComment( '__DTSUBSCRIBE__' . $threadItem->getName() );
|
|
|
|
|
2021-04-17 19:46:51 +00:00
|
|
|
$headingNode->appendChild( $subscribe );
|
2021-02-17 22:34:02 +00:00
|
|
|
}
|
|
|
|
}
|
2020-09-16 12:07:27 +00:00
|
|
|
} elseif ( $threadItem instanceof CommentItem ) {
|
|
|
|
$replyLinkButtons = $doc->createElement( 'span' );
|
2021-03-13 14:39:39 +00:00
|
|
|
$replyLinkButtons->setAttribute( 'class', 'ext-discussiontools-init-replylink-buttons' );
|
2020-09-16 12:07:27 +00:00
|
|
|
|
|
|
|
// Reply
|
|
|
|
$replyLink = $doc->createElement( 'a' );
|
2021-03-13 14:39:39 +00:00
|
|
|
$replyLink->setAttribute( 'class', 'ext-discussiontools-init-replylink-reply' );
|
2020-09-16 12:07:27 +00:00
|
|
|
$replyLink->setAttribute( 'role', 'button' );
|
|
|
|
$replyLink->setAttribute( 'tabindex', '0' );
|
|
|
|
$replyLink->setAttribute( 'data-mw-comment', $itemJSON );
|
2021-03-01 22:25:01 +00:00
|
|
|
// Set empty 'href' to avoid a:not([href]) selector in MobileFrontend
|
|
|
|
$replyLink->setAttribute( 'href', '' );
|
2021-04-15 21:09:55 +00:00
|
|
|
// Replaced in ::postprocessReplyTool() as the label depends on user language
|
|
|
|
$replyText = $doc->createComment( '__DTREPLY__' );
|
|
|
|
$replyLink->appendChild( $replyText );
|
2020-09-16 12:07:27 +00:00
|
|
|
|
|
|
|
$bracket = $doc->createElement( 'span' );
|
2021-03-13 14:39:39 +00:00
|
|
|
$bracket->setAttribute( 'class', 'ext-discussiontools-init-replylink-bracket' );
|
2020-09-16 12:07:27 +00:00
|
|
|
$bracketLeft = $bracket->cloneNode( false );
|
|
|
|
$bracketLeft->nodeValue = '[';
|
|
|
|
$bracketRight = $bracket->cloneNode( false );
|
|
|
|
$bracketRight->nodeValue = ']';
|
|
|
|
|
|
|
|
$replyLinkButtons->appendChild( $bracketLeft );
|
|
|
|
$replyLinkButtons->appendChild( $replyLink );
|
|
|
|
$replyLinkButtons->appendChild( $bracketRight );
|
|
|
|
|
|
|
|
CommentModifier::addReplyLink( $threadItem, $replyLinkButtons );
|
|
|
|
}
|
|
|
|
}
|
2020-11-16 15:50:46 +00:00
|
|
|
|
|
|
|
$docElement = $doc->getElementsByTagName( 'body' )->item( 0 );
|
|
|
|
if ( !( $docElement instanceof DOMElement ) ) {
|
|
|
|
return $html;
|
|
|
|
}
|
2021-02-13 19:01:58 +00:00
|
|
|
|
|
|
|
// Like DOMCompat::getInnerHTML(), but disable 'smartQuote' for compatibility with
|
|
|
|
// ParserOutput::EDITSECTION_REGEX matching 'mw:editsection' tags (T274709)
|
|
|
|
return XMLSerializer::serialize( $docElement, [ 'innerXML' => true, 'smartQuote' => false ] )['html'];
|
2020-09-16 12:07:27 +00:00
|
|
|
}
|
|
|
|
|
2021-04-19 19:14:07 +00:00
|
|
|
/**
|
|
|
|
* Replace placeholders for topic subscription buttons with the real thing.
|
|
|
|
*
|
|
|
|
* @param string $text
|
|
|
|
* @param Language $lang
|
|
|
|
* @param SubscriptionStore $subscriptionStore
|
|
|
|
* @param UserIdentity $user
|
|
|
|
* @return string
|
|
|
|
*/
|
|
|
|
public static function postprocessTopicSubscription(
|
|
|
|
string $text, Language $lang, SubscriptionStore $subscriptionStore, UserIdentity $user
|
|
|
|
) : string {
|
|
|
|
$doc = new DOMDocument();
|
2021-04-23 18:54:49 +00:00
|
|
|
|
|
|
|
$matches = [];
|
|
|
|
preg_match_all( '/<!--__DTSUBSCRIBE__(.*?)-->/', $text, $matches );
|
|
|
|
$itemNames = $matches[1];
|
|
|
|
|
|
|
|
$items = $subscriptionStore->getSubscriptionItemsForUser(
|
|
|
|
$user,
|
|
|
|
$itemNames
|
|
|
|
);
|
|
|
|
$itemsByName = [];
|
|
|
|
foreach ( $items as $item ) {
|
|
|
|
$itemsByName[ $item->getItemName() ] = $item;
|
|
|
|
}
|
|
|
|
|
2021-04-19 19:14:07 +00:00
|
|
|
$text = preg_replace_callback(
|
2021-04-19 20:33:45 +00:00
|
|
|
'/<!--__DTSUBSCRIBE__(.*?)-->/',
|
2021-04-23 18:54:49 +00:00
|
|
|
function ( $matches ) use ( $doc, $itemsByName ) {
|
2021-04-19 19:14:07 +00:00
|
|
|
$itemName = $matches[1];
|
2021-04-23 18:54:49 +00:00
|
|
|
$isSubscribed = isset( $itemsByName[ $itemName ] ) && !$itemsByName[ $itemName ]->isMuted();
|
2021-04-19 19:14:07 +00:00
|
|
|
$subscribe = $doc->createElement( 'span' );
|
|
|
|
$subscribe->setAttribute(
|
|
|
|
'class',
|
|
|
|
'ext-discussiontools-section-subscribe ' .
|
|
|
|
( $isSubscribed ? 'oo-ui-icon-unStar oo-ui-image-progressive' : 'oo-ui-icon-star' )
|
|
|
|
);
|
|
|
|
$subscribe->setAttribute( 'data-mw-comment-name', $itemName );
|
|
|
|
return DOMCompat::getOuterHTML( $subscribe );
|
|
|
|
},
|
|
|
|
$text
|
|
|
|
);
|
|
|
|
return $text;
|
|
|
|
}
|
|
|
|
|
2021-04-15 21:09:55 +00:00
|
|
|
/**
|
|
|
|
* Replace placeholders for reply links with the real thing.
|
|
|
|
*
|
|
|
|
* @param string $text
|
|
|
|
* @param Language $lang
|
|
|
|
* @return string
|
|
|
|
*/
|
|
|
|
public static function postprocessReplyTool(
|
|
|
|
string $text, Language $lang
|
|
|
|
) {
|
|
|
|
$replyText = wfMessage( 'discussiontools-replylink' )->inLanguage( $lang )->escaped();
|
|
|
|
$text = str_replace( '<!--__DTREPLY__-->', $replyText, $text );
|
|
|
|
return $text;
|
|
|
|
}
|
|
|
|
|
2020-09-16 12:07:27 +00:00
|
|
|
}
|