Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F4057069
CommentUtils.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
23 KB
Referenced Files
None
Subscribers
None
CommentUtils.php
View Options
<?php
namespace
MediaWiki\Extension\DiscussionTools
;
use
LogicException
;
use
MediaWiki\Config\Config
;
use
MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem
;
use
MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem
;
use
MediaWiki\MainConfigNames
;
use
MediaWiki\Title\Title
;
use
Wikimedia\Assert\Assert
;
use
Wikimedia\Parsoid\DOM\Comment
;
use
Wikimedia\Parsoid\DOM\Element
;
use
Wikimedia\Parsoid\DOM\Node
;
use
Wikimedia\Parsoid\DOM\Text
;
use
Wikimedia\Parsoid\Utils\DOMCompat
;
use
Wikimedia\Parsoid\Utils\DOMUtils
;
class
CommentUtils
{
private
function
__construct
()
{
}
private
const
BLOCK_ELEMENT_TYPES
=
[
'div'
,
'p'
,
// Tables
'table'
,
'tbody'
,
'thead'
,
'tfoot'
,
'caption'
,
'th'
,
'tr'
,
'td'
,
// Lists
'ul'
,
'ol'
,
'li'
,
'dl'
,
'dt'
,
'dd'
,
// HTML5 heading content
'h1'
,
'h2'
,
'h3'
,
'h4'
,
'h5'
,
'h6'
,
'hgroup'
,
// HTML5 sectioning content
'article'
,
'aside'
,
'body'
,
'nav'
,
'section'
,
'footer'
,
'header'
,
'figure'
,
'figcaption'
,
'fieldset'
,
'details'
,
'blockquote'
,
// Other
'hr'
,
'button'
,
'canvas'
,
'center'
,
'col'
,
'colgroup'
,
'embed'
,
'map'
,
'object'
,
'pre'
,
'progress'
,
'video'
];
/**
* @param Node $node
* @return bool Node is a block element
*/
public
static
function
isBlockElement
(
Node
$node
):
bool
{
return
$node
instanceof
Element
&&
in_array
(
strtolower
(
$node
->
tagName
),
static
::
BLOCK_ELEMENT_TYPES
,
true
);
}
private
const
SOL_TRANSPARENT_LINK_REGEX
=
'/(?:^|
\s
)mw:PageProp
\/
(?:Category|redirect|Language)(?=$|
\s
)/D'
;
/**
* @param Node $node
* @return bool Node is considered a rendering-transparent node in Parsoid
*/
public
static
function
isRenderingTransparentNode
(
Node
$node
):
bool
{
$nextSibling
=
$node
->
nextSibling
;
return
(
$node
instanceof
Comment
||
(
$node
instanceof
Element
&&
(
strtolower
(
$node
->
tagName
)
===
'meta'
||
(
strtolower
(
$node
->
tagName
)
===
'link'
&&
preg_match
(
static
::
SOL_TRANSPARENT_LINK_REGEX
,
$node
->
getAttribute
(
'rel'
)
??
''
)
)
||
// Empty inline templates, e.g. tracking templates. (T269036)
// But not empty nodes that are just the start of a non-empty template about-group. (T290940)
(
strtolower
(
$node
->
tagName
)
===
'span'
&&
in_array
(
'mw:Transclusion'
,
explode
(
' '
,
$node
->
getAttribute
(
'typeof'
)
??
''
),
true
)
&&
!
static
::
htmlTrim
(
DOMCompat
::
getInnerHTML
(
$node
)
)
&&
(
!
$nextSibling
||
!(
$nextSibling
instanceof
Element
)
||
// Maybe we should be checking all of the about-grouped nodes to see if they're empty,
// but that's prooobably not needed in practice, and it leads to a quadratic worst case.
$nextSibling
->
getAttribute
(
'about'
)
!==
$node
->
getAttribute
(
'about'
)
)
)
)
)
);
}
/**
* @param Node $node
* @return bool Node was added to the page by DiscussionTools
*/
public
static
function
isOurGeneratedNode
(
Node
$node
):
bool
{
return
$node
instanceof
Element
&&
(
DOMUtils
::
hasClass
(
$node
,
'ext-discussiontools-init-replylink-buttons'
)
||
$node
->
hasAttribute
(
'data-mw-comment-start'
)
||
$node
->
hasAttribute
(
'data-mw-comment-end'
)
);
}
/**
* Elements which can't have element children (but some may have text content).
*/
private
const
NO_ELEMENT_CHILDREN_ELEMENT_TYPES
=
[
// https://html.spec.whatwg.org/multipage/syntax.html#elements-2
// Void elements
'area'
,
'base'
,
'br'
,
'col'
,
'embed'
,
'hr'
,
'img'
,
'input'
,
'link'
,
'meta'
,
'param'
,
'source'
,
'track'
,
'wbr'
,
// Raw text elements
'script'
,
'style'
,
// Escapable raw text elements
'textarea'
,
'title'
,
// Foreign elements
'math'
,
'svg'
,
// Treated like text when scripting is enabled in the parser
// https://html.spec.whatwg.org/#the-noscript-element
'noscript'
,
// Replaced elements (that aren't already included above)
// https://html.spec.whatwg.org/multipage/rendering.html#replaced-elements
// They might allow element children, but they aren't rendered on the page.
'audio'
,
'canvas'
,
'iframe'
,
'object'
,
'video'
,
];
/**
* @param Node $node
* @return bool If true, node can't have element children. If false, it's complicated.
*/
public
static
function
cantHaveElementChildren
(
Node
$node
):
bool
{
return
(
$node
instanceof
Comment
||
(
$node
instanceof
Element
&&
(
in_array
(
strtolower
(
$node
->
tagName
),
static
::
NO_ELEMENT_CHILDREN_ELEMENT_TYPES
,
true
)
||
// Thumbnail wrappers generated by MediaTransformOutput::linkWrap (T301427),
// for compatibility with TimedMediaHandler.
// There is no better way to detect them, and we can't insert markers here,
// because the media DOM CSS depends on specific tag names and their order :(
// TODO See if we can remove this condition when wgParserEnableLegacyMediaDOM=false
// is enabled everywhere.
(
in_array
(
strtolower
(
$node
->
tagName
),
[
'a'
,
'span'
],
true
)
&&
$node
->
firstChild
&&
// We always step inside a child node so this can't be infinite, silly Phan
// @phan-suppress-next-line PhanInfiniteRecursion
static
::
cantHaveElementChildren
(
$node
->
firstChild
)
)
||
// Do not insert anything inside figures when using wgParserEnableLegacyMediaDOM=false,
// because their CSS can't handle it (T320285).
strtolower
(
$node
->
tagName
)
===
'figure'
)
)
);
}
/**
* Check whether the node is a comment separator (instead of a part of the comment).
*/
public
static
function
isCommentSeparator
(
Node
$node
):
bool
{
if
(
!(
$node
instanceof
Element
)
)
{
return
false
;
}
$tagName
=
strtolower
(
$node
->
tagName
);
if
(
$tagName
===
'br'
||
$tagName
===
'hr'
)
{
return
true
;
}
// TemplateStyles followed by any of the others
if
(
$node
->
nextSibling
&&
(
$tagName
===
'link'
||
$tagName
===
'style'
)
&&
self
::
isCommentSeparator
(
$node
->
nextSibling
)
)
{
return
true
;
}
// Anything marked as not containing comments
// {{outdent}} templates
// {{tracked}} templates (T313097)
if
(
DOMUtils
::
hasClass
(
$node
,
'mw-notalk|outdent-template|mw-trackedTemplate'
)
)
{
return
true
;
}
// Wikitext definition list term markup (`;`) when used as a fake heading (T265964)
if
(
$tagName
===
'dl'
&&
count
(
$node
->
childNodes
)
===
1
&&
$node
->
firstChild
instanceof
Element
&&
strtolower
(
$node
->
firstChild
->
nodeName
)
===
'dt'
)
{
return
true
;
}
return
false
;
}
/**
* Check whether the node is a comment content. It's a little vague what this means…
*
* @param Node $node Node, should be a leaf node (a node with no children)
* @return bool
*/
public
static
function
isCommentContent
(
Node
$node
):
bool
{
return
(
$node
instanceof
Text
&&
static
::
htmlTrim
(
$node
->
nodeValue
??
''
)
!==
''
)
||
(
static
::
cantHaveElementChildren
(
$node
)
);
}
/**
* Get the index of $child in its parent
*/
public
static
function
childIndexOf
(
Node
$child
):
int
{
$i
=
0
;
while
(
(
$child
=
$child
->
previousSibling
)
)
{
$i
++;
}
return
$i
;
}
/**
* Check whether a Node contains (is an ancestor of) another Node (or is the same node)
*/
public
static
function
contains
(
Node
$ancestor
,
Node
$descendant
):
bool
{
// TODO can we use Node->compareDocumentPosition() here maybe?
$node
=
$descendant
;
while
(
$node
&&
$node
!==
$ancestor
)
{
$node
=
$node
->
parentNode
;
}
return
$node
===
$ancestor
;
}
/**
* Find closest ancestor element using one of the given tag names.
*
* @param Node $node
* @param string[] $tagNames
* @return Element|null
*/
public
static
function
closestElement
(
Node
$node
,
array
$tagNames
):
?
Element
{
do
{
if
(
$node
instanceof
Element
&&
in_array
(
strtolower
(
$node
->
tagName
),
$tagNames
,
true
)
)
{
return
$node
;
}
$node
=
$node
->
parentNode
;
}
while
(
$node
);
return
null
;
}
/**
* Find closest ancestor element that has sibling nodes
*
* @param Node $node
* @param string $direction Can be 'next', 'previous', or 'either'
* @return Element|null
*/
public
static
function
closestElementWithSibling
(
Node
$node
,
string
$direction
):
?
Element
{
do
{
if
(
$node
instanceof
Element
&&
(
(
$node
->
nextSibling
&&
(
$direction
===
'next'
||
$direction
===
'either'
)
)
||
(
$node
->
previousSibling
&&
(
$direction
===
'previous'
||
$direction
===
'either'
)
)
)
)
{
return
$node
;
}
$node
=
$node
->
parentNode
;
}
while
(
$node
);
return
null
;
}
/**
* Find the transclusion node which rendered the current node, if it exists.
*
* 1. Find the closest ancestor with an 'about' attribute
* 2. Find the main node of the about-group (first sibling with the same 'about' attribute)
* 3. If this is an mw:Transclusion node, return it; otherwise, go to step 1
*
* @param Node $node
* @return Element|null Transclusion node, null if not found
*/
public
static
function
getTranscludedFromElement
(
Node
$node
):
?
Element
{
while
(
$node
)
{
// 1.
if
(
$node
instanceof
Element
&&
$node
->
getAttribute
(
'about'
)
&&
preg_match
(
'/^#mwt
\d
+$/'
,
$node
->
getAttribute
(
'about'
)
??
''
)
)
{
$about
=
$node
->
getAttribute
(
'about'
);
// 2.
while
(
(
$previousSibling
=
$node
->
previousSibling
)
&&
$previousSibling
instanceof
Element
&&
$previousSibling
->
getAttribute
(
'about'
)
===
$about
)
{
$node
=
$previousSibling
;
}
// 3.
if
(
$node
->
getAttribute
(
'typeof'
)
&&
in_array
(
'mw:Transclusion'
,
explode
(
' '
,
$node
->
getAttribute
(
'typeof'
)
??
''
),
true
)
)
{
break
;
}
}
$node
=
$node
->
parentNode
;
}
return
$node
;
}
/**
* Given a heading node, return the node on which the ID attribute is set.
*
* @param Element $heading Heading node (`<h1>`-`<h6>`)
* @return Element Headline node, normally also a `<h1>`-`<h6>` element.
* In integration tests and in JS, it can be a `<span class="mw-headline">` (see T363031).
*/
public
static
function
getHeadlineNode
(
Element
$heading
):
Element
{
// This code assumes that $wgFragmentMode is [ 'html5', 'legacy' ] or [ 'html5' ]
$headline
=
$heading
;
if
(
$headline
->
hasAttribute
(
'data-mw-comment-start'
)
)
{
// HACK: For contaminated integration tests only (see T363031)
$headline
=
$headline
->
parentNode
;
Assert
::
precondition
(
$headline
!==
null
,
'data-mw-comment-start was attached to a heading'
);
}
if
(
!
$headline
->
getAttribute
(
'id'
)
&&
!
$headline
->
getAttribute
(
'data-mw-anchor'
)
)
{
// HACK: For outdated integration tests only (see T363031)
$headline
=
DOMCompat
::
querySelector
(
$headline
,
'.mw-headline'
);
if
(
!
$headline
)
{
$headline
=
$heading
;
}
}
return
$headline
;
}
/**
* Trim ASCII whitespace, as defined in the HTML spec.
*/
public
static
function
htmlTrim
(
string
$str
):
string
{
// https://infra.spec.whatwg.org/#ascii-whitespace
return
trim
(
$str
,
"
\t\n\f\r
"
);
}
/**
* Get the indent level of $node, relative to $rootNode.
*
* The indent level is the number of lists inside of which it is nested.
*/
public
static
function
getIndentLevel
(
Node
$node
,
Element
$rootNode
):
int
{
$indent
=
0
;
while
(
$node
)
{
if
(
$node
===
$rootNode
)
{
break
;
}
$tagName
=
$node
instanceof
Element
?
strtolower
(
$node
->
tagName
)
:
null
;
if
(
$tagName
===
'li'
||
$tagName
===
'dd'
)
{
$indent
++;
}
$node
=
$node
->
parentNode
;
}
return
$indent
;
}
/**
* Get an array of sibling nodes that contain parts of the given range.
*
* @param ImmutableRange $range
* @return Node[]
*/
public
static
function
getCoveredSiblings
(
ImmutableRange
$range
):
array
{
$ancestor
=
$range
->
commonAncestorContainer
;
// Convert to array early because apparently NodeList acts like a linked list
// and accessing items by index is slow
$siblings
=
iterator_to_array
(
$ancestor
->
childNodes
);
$start
=
0
;
$end
=
count
(
$siblings
)
-
1
;
// Find first of the siblings that contains the item
if
(
$ancestor
===
$range
->
startContainer
)
{
$start
=
$range
->
startOffset
;
}
else
{
while
(
!
static
::
contains
(
$siblings
[
$start
],
$range
->
startContainer
)
)
{
$start
++;
}
}
// Find last of the siblings that contains the item
if
(
$ancestor
===
$range
->
endContainer
)
{
$end
=
$range
->
endOffset
-
1
;
}
else
{
while
(
!
static
::
contains
(
$siblings
[
$end
],
$range
->
endContainer
)
)
{
$end
--;
}
}
return
array_slice
(
$siblings
,
$start
,
$end
-
$start
+
1
);
}
/**
* Get the nodes (if any) that contain the given thread item, and nothing else.
*
* @param ContentThreadItem $item
* @param ?Node $excludedAncestorNode Node that shouldn't be included in the result, even if it
* contains the item and nothing else. This is intended to avoid traversing outside of a node
* which is a container for all the thread items.
* @return Node[]|null
*/
public
static
function
getFullyCoveredSiblings
(
ContentThreadItem
$item
,
?
Node
$excludedAncestorNode
=
null
):
?
array
{
$siblings
=
static
::
getCoveredSiblings
(
$item
->
getRange
()
);
$makeRange
=
static
function
(
$siblings
)
{
return
new
ImmutableRange
(
$siblings
[
0
]->
parentNode
,
CommentUtils
::
childIndexOf
(
$siblings
[
0
]
),
end
(
$siblings
)->
parentNode
,
CommentUtils
::
childIndexOf
(
end
(
$siblings
)
)
+
1
);
};
$matches
=
static
::
compareRanges
(
$makeRange
(
$siblings
),
$item
->
getRange
()
)
===
'equal'
;
if
(
$matches
)
{
// If these are all of the children (or the only child), go up one more level
while
(
(
$parent
=
$siblings
[
0
]->
parentNode
)
&&
$parent
!==
$excludedAncestorNode
&&
static
::
compareRanges
(
$makeRange
(
[
$parent
]
),
$item
->
getRange
()
)
===
'equal'
)
{
$siblings
=
[
$parent
];
}
return
$siblings
;
}
return
null
;
}
/**
* Unwrap Parsoid sections
*
* @param Element $element Parent element, e.g. document body
*/
public
static
function
unwrapParsoidSections
(
Element
$element
):
void
{
$sections
=
DOMCompat
::
querySelectorAll
(
$element
,
'section[data-mw-section-id]'
);
foreach
(
$sections
as
$section
)
{
$parent
=
$section
->
parentNode
;
while
(
$section
->
firstChild
)
{
$parent
->
insertBefore
(
$section
->
firstChild
,
$section
);
}
$parent
->
removeChild
(
$section
);
}
}
/**
* Get a MediaWiki page title from a URL
*
* @param string $url Relative URL (from a `href` attribute)
* @param Config $config Config settings needed to resolve the relative URL
* @return string|null
*/
public
static
function
getTitleFromUrl
(
string
$url
,
Config
$config
):
?
string
{
// Protocol-relative URLs are handled really badly by parse_url()
if
(
str_starts_with
(
$url
,
'//'
)
)
{
$url
=
"http:$url"
;
}
$bits
=
parse_url
(
$url
);
$query
=
wfCgiToArray
(
$bits
[
'query'
]
??
''
);
if
(
isset
(
$query
[
'title'
]
)
)
{
return
$query
[
'title'
];
}
// TODO: Set the correct base in the document?
$articlePath
=
$config
->
get
(
MainConfigNames
::
ArticlePath
);
if
(
str_starts_with
(
$url
,
'./'
)
)
{
// Assume this is URL in the format used by Parsoid documents
$url
=
substr
(
$url
,
2
);
$path
=
str_replace
(
'$1'
,
$url
,
$articlePath
);
}
elseif
(
!
str_contains
(
$url
,
'://'
)
)
{
// Assume this is URL in the format used by legacy parser documents
$path
=
$url
;
}
else
{
// External link
$path
=
$bits
[
'path'
]
??
''
;
}
$articlePathRegexp
=
'/^'
.
str_replace
(
'
\\
$1'
,
'([^?]*)'
,
preg_quote
(
$articlePath
,
'/'
)
)
.
'/'
;
$matches
=
null
;
if
(
preg_match
(
$articlePathRegexp
,
$path
,
$matches
)
)
{
return
rawurldecode
(
$matches
[
1
]
);
}
return
null
;
}
/**
* Traverse the document in depth-first order, calling the callback whenever entering and leaving
* a node. The walk starts before the given node and ends when callback returns a truthy value, or
* after reaching the end of the document.
*
* You might also think about this as processing XML token stream linearly (rather than XML
* nodes), as if we were parsing the document.
*
* @param Node $node Node to start at
* @param callable $callback Function accepting two arguments: $event ('enter' or 'leave') and
* $node (Node)
* @return mixed Final return value of the callback
*/
public
static
function
linearWalk
(
Node
$node
,
callable
$callback
)
{
$result
=
null
;
[
$withinNode
,
$beforeNode
]
=
[
$node
->
parentNode
,
$node
];
while
(
$beforeNode
||
$withinNode
)
{
if
(
$beforeNode
)
{
$result
=
$callback
(
'enter'
,
$beforeNode
);
[
$withinNode
,
$beforeNode
]
=
[
$beforeNode
,
$beforeNode
->
firstChild
];
}
else
{
$result
=
$callback
(
'leave'
,
$withinNode
);
[
$withinNode
,
$beforeNode
]
=
[
$withinNode
->
parentNode
,
$withinNode
->
nextSibling
];
}
if
(
$result
)
{
return
$result
;
}
}
return
$result
;
}
/**
* Like #linearWalk, but it goes backwards.
*
* @inheritDoc ::linearWalk()
*/
public
static
function
linearWalkBackwards
(
Node
$node
,
callable
$callback
)
{
$result
=
null
;
[
$withinNode
,
$beforeNode
]
=
[
$node
->
parentNode
,
$node
];
while
(
$beforeNode
||
$withinNode
)
{
if
(
$beforeNode
)
{
$result
=
$callback
(
'enter'
,
$beforeNode
);
[
$withinNode
,
$beforeNode
]
=
[
$beforeNode
,
$beforeNode
->
lastChild
];
}
else
{
$result
=
$callback
(
'leave'
,
$withinNode
);
[
$withinNode
,
$beforeNode
]
=
[
$withinNode
->
parentNode
,
$withinNode
->
previousSibling
];
}
if
(
$result
)
{
return
$result
;
}
}
return
$result
;
}
/**
* @param ImmutableRange $range (must not be collapsed)
* @return Node
*/
public
static
function
getRangeFirstNode
(
ImmutableRange
$range
):
Node
{
Assert
::
precondition
(
!
$range
->
collapsed
,
'Range is not collapsed'
);
// PHP bug: childNodes can be null
return
$range
->
startContainer
->
childNodes
&&
$range
->
startContainer
->
childNodes
->
length
?
$range
->
startContainer
->
childNodes
[
$range
->
startOffset
]
:
$range
->
startContainer
;
}
/**
* @param ImmutableRange $range (must not be collapsed)
* @return Node
*/
public
static
function
getRangeLastNode
(
ImmutableRange
$range
):
Node
{
Assert
::
precondition
(
!
$range
->
collapsed
,
'Range is not collapsed'
);
// PHP bug: childNodes can be null
return
$range
->
endContainer
->
childNodes
&&
$range
->
endContainer
->
childNodes
->
length
?
$range
->
endContainer
->
childNodes
[
$range
->
endOffset
-
1
]
:
$range
->
endContainer
;
}
/**
* Check whether two ranges overlap, and how.
*
* Includes a hack to check for "almost equal" ranges (whose start/end boundaries only differ by
* "uninteresting" nodes that we ignore when detecting comments), and treat them as equal.
*
* Illustration of return values:
* [ equal ]
* |[ contained ]|
* [ | contains | ]
* [overlap|start] |
* | [overlap|end]
* [before] | |
* | | [after]
*
* @param ImmutableRange $a
* @param ImmutableRange $b
* @return string One of:
* - 'equal': Ranges A and B are equal
* - 'contains': Range A contains range B
* - 'contained': Range A is contained within range B
* - 'after': Range A is before range B
* - 'before': Range A is after range B
* - 'overlapstart': Start of range A overlaps range B
* - 'overlapend': End of range A overlaps range B
*/
public
static
function
compareRanges
(
ImmutableRange
$a
,
ImmutableRange
$b
):
string
{
// Compare the positions of: start of A to start of B, start of A to end of B, and so on.
// Watch out, the constant names are the opposite of what they should be.
$startToStart
=
$a
->
compareBoundaryPoints
(
ImmutableRange
::
START_TO_START
,
$b
);
$startToEnd
=
$a
->
compareBoundaryPoints
(
ImmutableRange
::
END_TO_START
,
$b
);
$endToStart
=
$a
->
compareBoundaryPoints
(
ImmutableRange
::
START_TO_END
,
$b
);
$endToEnd
=
$a
->
compareBoundaryPoints
(
ImmutableRange
::
END_TO_END
,
$b
);
// Handle almost equal ranges: When start or end boundary points of the two ranges are different,
// but only differ by "uninteresting" nodes, treat them as equal instead.
if
(
(
$startToStart
<
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$a
,
$b
,
'start'
)
)
||
(
$startToStart
>
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$b
,
$a
,
'start'
)
)
)
{
$startToStart
=
0
;
}
if
(
(
$endToEnd
<
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$a
,
$b
,
'end'
)
)
||
(
$endToEnd
>
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$b
,
$a
,
'end'
)
)
)
{
$endToEnd
=
0
;
}
if
(
$startToStart
===
0
&&
$endToEnd
===
0
)
{
return
'equal'
;
}
if
(
$startToStart
<=
0
&&
$endToEnd
>=
0
)
{
return
'contains'
;
}
if
(
$startToStart
>=
0
&&
$endToEnd
<=
0
)
{
return
'contained'
;
}
if
(
$startToEnd
>=
0
)
{
return
'after'
;
}
if
(
$endToStart
<=
0
)
{
return
'before'
;
}
if
(
$startToStart
>
0
&&
$startToEnd
<
0
&&
$endToEnd
>=
0
)
{
return
'overlapstart'
;
}
if
(
$endToEnd
<
0
&&
$endToStart
>
0
&&
$startToStart
<=
0
)
{
return
'overlapend'
;
}
throw
new
LogicException
(
'Unreachable'
);
}
/**
* Check if the given boundary points of ranges A and B are almost equal (only differing by
* uninteresting nodes).
*
* Boundary of A must be before the boundary of B in the tree.
*
* @param ImmutableRange $a
* @param ImmutableRange $b
* @param string $boundary 'start' or 'end'
* @return bool
*/
private
static
function
compareRangesAlmostEqualBoundaries
(
ImmutableRange
$a
,
ImmutableRange
$b
,
string
$boundary
):
bool
{
// This code is awful, but several attempts to rewrite it made it even worse.
// You're welcome to give it a try.
$from
=
$boundary
===
'end'
?
static
::
getRangeLastNode
(
$a
)
:
static
::
getRangeFirstNode
(
$a
);
$to
=
$boundary
===
'end'
?
static
::
getRangeLastNode
(
$b
)
:
static
::
getRangeFirstNode
(
$b
);
$skipNode
=
null
;
if
(
$boundary
===
'end'
)
{
$skipNode
=
$from
;
}
$foundContent
=
false
;
static
::
linearWalk
(
$from
,
static
function
(
string
$event
,
Node
$n
)
use
(
$from
,
$to
,
$boundary
,
&
$skipNode
,
&
$foundContent
)
{
if
(
$n
===
$to
&&
$event
===
(
$boundary
===
'end'
?
'leave'
:
'enter'
)
)
{
return
true
;
}
if
(
$skipNode
)
{
if
(
$n
===
$skipNode
&&
$event
===
'leave'
)
{
$skipNode
=
null
;
}
return
;
}
if
(
$event
===
'enter'
)
{
if
(
CommentUtils
::
isCommentSeparator
(
$n
)
||
CommentUtils
::
isRenderingTransparentNode
(
$n
)
||
CommentUtils
::
isOurGeneratedNode
(
$n
)
)
{
$skipNode
=
$n
;
}
elseif
(
CommentUtils
::
isCommentContent
(
$n
)
)
{
$foundContent
=
true
;
return
true
;
}
}
}
);
return
!
$foundContent
;
}
/**
* Check whether the last item in the thread item set is a properly signed comment by
* the expected author (that is: there is a signature, and either there's nothing following the
* signature, or there's some text within the same paragraph that was detected as part of the same
* comment).
*/
public
static
function
isSingleCommentSignedBy
(
ContentThreadItemSet
$itemSet
,
string
$author
,
Element
$rootNode
):
bool
{
$items
=
$itemSet
->
getThreadItems
();
if
(
$items
)
{
$lastItem
=
end
(
$items
);
// Check that we've detected a comment first, not just headings (T304377)
if
(
!(
$lastItem
instanceof
ContentCommentItem
&&
$lastItem
->
getAuthor
()
===
$author
)
)
{
return
false
;
}
$commentRange
=
$lastItem
->
getRange
();
$bodyRange
=
new
ImmutableRange
(
$rootNode
,
0
,
$rootNode
,
count
(
$rootNode
->
childNodes
)
);
// Only check that the end of the comment range is at the end of the body range.
// We don't care about preceding headings, comments, or other content (T363285).
// This is a simplified fragment of static::compareRanges().
$cmp
=
$commentRange
->
compareBoundaryPoints
(
ImmutableRange
::
END_TO_END
,
$bodyRange
);
if
(
$cmp
===
0
||
(
$cmp
<
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$commentRange
,
$bodyRange
,
'end'
)
)
||
(
$cmp
>
0
&&
static
::
compareRangesAlmostEqualBoundaries
(
$bodyRange
,
$commentRange
,
'end'
)
)
)
{
// New comment includes a signature in the proper place
return
true
;
}
}
return
false
;
}
/**
* Get the ID for a new topics subscription from a page title
*
* @param Title $title Page title
* @return string ID for a new topics subscription
*/
public
static
function
getNewTopicsSubscriptionId
(
Title
$title
)
{
return
"p-topics-{$title->getNamespace()}:{$title->getDBkey()}"
;
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Aug 16 2026, 03:49 (13 w, 7 h ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
53/32/fa86a5cb2cb4f016c53a6e99c802
Default Alt Text
CommentUtils.php (23 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment