mediawiki-extensions-Discus.../includes/ApiDiscussionToolsPageInfo.php
David Lynch ec0e2920ae API ThreadItemsHTML: improve generation of othercontent
Othercontent would often contain the opening tag of the next heading /
section. By looking for the closest node with a previousSibling we can
more-reliably escape the heading.

Also, only add the initial placeholder if there's content before the
first heading. We do this by testing for any siblings before the
startContainer of the first heading -- if there are any, assume this
means there's some sort of content. (This can still result in a
placeholder with `othercontent:""` if there's only whitespace before
the first heading.)

Bug: T313850
Change-Id: I080205b74413c46d3cf3442e79276145aaa9439c
2022-07-28 02:51:18 -05:00

232 lines
7.4 KiB
PHP

<?php
namespace MediaWiki\Extension\DiscussionTools;
use ApiBase;
use ApiMain;
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
use Title;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Text;
use Wikimedia\Parsoid\Utils\DOMUtils;
class ApiDiscussionToolsPageInfo 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'] );
$prop = array_fill_keys( $params['prop'], true );
if ( !$title ) {
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
}
$revision = $this->getValidRevision( $title, $params['oldid'] ?? null );
$threadItemSet = $this->parseRevision( $revision );
$result = [];
if ( isset( $prop['transcludedfrom'] ) ) {
$result['transcludedfrom'] = static::getTranscludedFrom( $threadItemSet );
}
if ( isset( $prop['threaditemshtml'] ) ) {
$result['threaditemshtml'] = static::getThreadItemsHtml( $threadItemSet );
}
$this->getResult()->addValue( null, $this->getModuleName(), $result );
}
/**
* Get transcluded=from data for a ContentThreadItemSet
*
* @param ContentThreadItemSet $threadItemSet
* @return array
*/
private static function getTranscludedFrom( ContentThreadItemSet $threadItemSet ): array {
$threadItems = $threadItemSet->getThreadItems();
$transcludedFrom = [];
foreach ( $threadItems as $threadItem ) {
$from = $threadItem->getTranscludedFrom();
// Key by IDs, legacy IDs, and names. This assumes that they can never conflict.
$transcludedFrom[ $threadItem->getId() ] = $from;
$legacyId = $threadItem->getLegacyId();
if ( $legacyId ) {
$transcludedFrom[ $legacyId ] = $from;
}
$name = $threadItem->getName();
if ( isset( $transcludedFrom[ $name ] ) && $transcludedFrom[ $name ] !== $from ) {
// Two or more items with the same name, transcluded from different pages.
// Consider them both to be transcluded from unknown source.
$transcludedFrom[ $name ] = true;
} else {
$transcludedFrom[ $name ] = $from;
}
}
return $transcludedFrom;
}
/**
* Get thread items HTML for a ContentThreadItemSet
*
* @param ContentThreadItemSet $threadItemSet
* @return array
*/
private static function getThreadItemsHtml( ContentThreadItemSet $threadItemSet ): array {
// This function assumes that the start of the ranges associated with
// HeadingItems are going to be at the start of their associated
// heading node (`<h2>^heading</h2>`), i.e. in the position generated
// by getHeadlineNodeAndOffset.
$threads = $threadItemSet->getThreads();
if ( count( $threads ) > 0 && !$threads[0]->isPlaceholderHeading() ) {
$firstHeading = $threads[0];
$firstRange = $firstHeading->getRange();
$rootNode = $firstHeading->getRootNode();
// We need a placeholder if there's content between the beginning
// of rootnode and the start of firstHeading. An ancestor of the
// first heading with a previousSibling is evidence that there's
// probably content. If this is giving false positives we could
// perhaps use linearWalkBackwards and DomUtils::isContentNode.
$closest = CommentUtils::closestElementWithSibling( $firstRange->startContainer, 'previous' );
if ( $closest && !$rootNode->isSameNode( $closest ) ) {
$range = new ImmutableRange( $rootNode, 0, $rootNode, 0 );
$fakeHeading = new ContentHeadingItem( $range, null );
$fakeHeading->setRootNode( $rootNode );
$fakeHeading->setName( 'h-' );
$fakeHeading->setId( 'h-' );
array_unshift( $threads, $fakeHeading );
}
}
$output = array_map( static function ( ContentThreadItem $item ) {
return $item->jsonSerialize( true, static function ( array &$array, ContentThreadItem $item ) {
$array['html'] = $item->getHtml();
} );
}, $threads );
foreach ( $threads as $index => $item ) {
// need to loop over this to fix up empty sections, because we
// need context that's not available inside the array map
if ( $item instanceof ContentHeadingItem && count( $item->getReplies() ) === 0 ) {
// If there are no replies we want to include whatever's
// inside this section as "othercontent". We create a range
// that's between the end of this section's heading and the
// start of next section's heading. The main difficulty here
// is avoiding catching any of the heading's tags within the
// range.
$nextItem = $threads[ $index + 1 ] ?? false;
$startRange = $item->getRange();
if ( $item->isPlaceholderHeading() ) {
// Placeholders don't have any heading to avoid
$startNode = $startRange->startContainer;
$startOffset = $startRange->startOffset;
} else {
$startNode = CommentUtils::closestElementWithSibling( $startRange->endContainer, 'next' );
$startNode = $startNode->nextSibling;
$startOffset = 0;
}
if ( !$startNode ) {
$startNode = $startRange->endContainer;
$startOffset = $startRange->endOffset;
}
if ( $nextItem ) {
$nextStart = $nextItem->getRange()->startContainer;
$endContainer = CommentUtils::closestElementWithSibling( $nextStart, 'previous' );
$endContainer = $endContainer && $endContainer->previousSibling ?
$endContainer->previousSibling : $nextStart;
$endOffset = CommentUtils::childIndexOf( $endContainer );
if ( $endContainer instanceof Text ) {
// This probably means that there's a wrapping node
// e.g. <div>foo\n==heading==\nbar</div>
$endOffset += $endContainer->length;
} elseif ( $endContainer instanceof Element && $endContainer->tagName === 'section' ) {
// if we're in sections, make sure we're selecting the
// end of the previous section
$endOffset = $endContainer->childNodes->length;
} elseif ( $endContainer->parentNode ) {
$endContainer = $endContainer->parentNode;
}
$betweenRange = new ImmutableRange(
$startNode, $startOffset,
$endContainer ?: $nextStart, $endOffset
);
} else {
// This is the last section, so we want to go to the end of the rootnode
$betweenRange = new ImmutableRange(
$startNode, $startOffset,
$item->getRootNode(), $item->getRootNode()->childNodes->length
);
}
$fragment = $betweenRange->cloneContents();
CommentModifier::unwrapFragment( $fragment );
$output[$index]['othercontent'] = trim( DOMUtils::getFragmentInnerHTML( $fragment ) );
}
}
return $output;
}
/**
* @inheritDoc
*/
public function getAllowedParams() {
return [
'page' => [
ParamValidator::PARAM_REQUIRED => true,
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
],
'oldid' => null,
'prop' => [
ParamValidator::PARAM_DEFAULT => 'transcludedfrom',
ParamValidator::PARAM_ISMULTI => true,
ParamValidator::PARAM_TYPE => [
'transcludedfrom',
'threaditemshtml'
],
ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
],
];
}
/**
* @inheritDoc
*/
public function needsToken() {
return false;
}
/**
* @inheritDoc
*/
public function isInternal() {
return true;
}
/**
* @inheritDoc
*/
public function isWriteMode() {
return false;
}
}