Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.06% covered (success)
94.06%
649 / 690
65.38% covered (warning)
65.38%
17 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentParser
94.06% covered (success)
94.06%
649 / 690
65.38% covered (warning)
65.38%
17 / 26
260.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 nextInterestingLeafNode
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
 regexpAlternateGroup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getMessages
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getTimestampRegexp
88.54% covered (warning)
88.54%
85 / 96
0.00% covered (danger)
0.00%
0 / 1
32.45
 getTimestampParser
93.85% covered (success)
93.85%
122 / 130
0.00% covered (danger)
0.00%
0 / 1
52.63
 getLocalTimestampRegexps
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getLocalTimestampParsers
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getUsernameFromLink
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
17
 findSignature
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
15
 acceptOnlyNodesAllowingComments
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
11
 getCodepointOffset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findTimestamp
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
11
 adjustSigRange
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 buildThreadItems
98.96% covered (success)
98.96%
95 / 96
0.00% covered (danger)
0.00%
0 / 1
22
 computeTranscludedFrom
69.23% covered (warning)
69.23%
36 / 52
0.00% covered (danger)
0.00%
0 / 1
45.69
 titleCanExist
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 parseTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getTransclusionTitles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getTransclusionRange
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 truncateForId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 computeId
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
13
 computeName
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 buildThreads
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 computeIdsAndNames
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use DateInterval;
6use DateTime;
7use DateTimeImmutable;
8use DateTimeZone;
9use InvalidArgumentException;
10use LogicException;
11use MediaWiki\Config\Config;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
14use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
15use MediaWiki\Language\Language;
16use MediaWiki\Languages\LanguageConverterFactory;
17use MediaWiki\Title\MalformedTitleException;
18use MediaWiki\Title\TitleParser;
19use MediaWiki\Title\TitleValue;
20use MediaWiki\Utils\MWTimestamp;
21use RuntimeException;
22use Wikimedia\Assert\Assert;
23use Wikimedia\IPUtils;
24use Wikimedia\Parsoid\DOM\Element;
25use Wikimedia\Parsoid\DOM\Node;
26use Wikimedia\Parsoid\DOM\Text;
27use Wikimedia\Parsoid\Utils\DOMCompat;
28use Wikimedia\Parsoid\Utils\DOMUtils;
29use Wikimedia\Timestamp\TimestampException;
30
31// TODO consider making timestamp parsing not a returned function
32
33class CommentParser {
34
35    /**
36     * How far backwards we look for a signature associated with a timestamp before giving up.
37     * Note that this is not a hard limit on the length of signatures we detect.
38     */
39    private const SIGNATURE_SCAN_LIMIT = 100;
40
41    /** @var string[] */
42    private array $dateFormat;
43    /** @var string[][] */
44    private array $digits;
45    /** @var string[][] */
46    private $contLangMessages;
47    private string $localTimezone;
48    /** @var string[][] */
49    private array $timezones;
50    private string $specialContributionsName;
51
52    private Element $rootNode;
53    private TitleValue $title;
54
55    public function __construct(
56        private readonly Config $config,
57        private readonly Language $language,
58        private readonly LanguageConverterFactory $languageConverterFactory,
59        LanguageData $languageData,
60        private readonly TitleParser $titleParser,
61    ) {
62        $data = $languageData->getLocalData();
63        $this->dateFormat = $data['dateFormat'];
64        $this->digits = $data['digits'];
65        $this->contLangMessages = $data['contLangMessages'];
66        $this->localTimezone = $data['localTimezone'];
67        $this->timezones = $data['timezones'];
68        $this->specialContributionsName = $data['specialContributionsName'];
69    }
70
71    /**
72     * Parse a discussion page.
73     *
74     * @param Element $rootNode Root node of content to parse
75     * @param TitleValue $title Title of the page being parsed
76     */
77    public function parse( Element $rootNode, TitleValue $title ): ContentThreadItemSet {
78        $this->rootNode = $rootNode;
79        $this->title = $title;
80
81        $result = $this->buildThreadItems();
82        $this->buildThreads( $result );
83        $this->computeIdsAndNames( $result );
84
85        return $result;
86    }
87
88    /**
89     * Return the next leaf node in the tree order that is likely a part of a discussion comment,
90     * rather than some boring "separator" element.
91     *
92     * Currently, this can return a Text node with content other than whitespace, or an Element node
93     * that is a "void element" or "text element", except some special cases that we treat as comment
94     * separators (isCommentSeparator()).
95     *
96     * @param ?Node $node Node after which to start searching
97     *   (if null, start at the beginning of the document).
98     */
99    private function nextInterestingLeafNode( ?Node $node ): Node {
100        $rootNode = $this->rootNode;
101        $treeWalker = new TreeWalker(
102            $rootNode,
103            NodeFilter::SHOW_ELEMENT | NodeFilter::SHOW_TEXT,
104            static function ( $n ) use ( $node ) {
105                // Skip past the starting node and its descendants
106                if ( $n === $node || $n->parentNode === $node ) {
107                    return NodeFilter::FILTER_REJECT;
108                }
109                // Ignore some elements usually used as separators or headers (and their descendants)
110                if ( CommentUtils::isCommentSeparator( $n ) ) {
111                    return NodeFilter::FILTER_REJECT;
112                }
113                // Ignore nodes with no rendering that mess up our indentation detection
114                if ( CommentUtils::isRenderingTransparentNode( $n ) ) {
115                    return NodeFilter::FILTER_REJECT;
116                }
117                if ( CommentUtils::isCommentContent( $n ) ) {
118                    return NodeFilter::FILTER_ACCEPT;
119                }
120                return NodeFilter::FILTER_SKIP;
121            }
122        );
123        if ( $node ) {
124            $treeWalker->currentNode = $node;
125        }
126        $treeWalker->nextNode();
127        if ( !$treeWalker->currentNode ) {
128            throw new RuntimeException( 'nextInterestingLeafNode not found' );
129        }
130        return $treeWalker->currentNode;
131    }
132
133    /**
134     * @param string[] $values Values to match
135     * @return string Regular expression
136     */
137    private static function regexpAlternateGroup( array $values ): string {
138        return '(' . implode( '|', array_map( static function ( string $x ) {
139            return preg_quote( $x, '/' );
140        }, $values ) ) . ')';
141    }
142
143    /**
144     * Get text of localisation messages in content language.
145     *
146     * @param string $contLangVariant Content language variant
147     * @param string[] $messages Message keys
148     * @return string[] Message values
149     */
150    private function getMessages( string $contLangVariant, array $messages ): array {
151        return array_map( function ( string $key ) use ( $contLangVariant ) {
152            return $this->contLangMessages[$contLangVariant][$key];
153        }, $messages );
154    }
155
156    /**
157     * Get a regexp that matches timestamps generated using the given date format.
158     *
159     * This only supports format characters that are used by the default date format in any of
160     * MediaWiki's languages, namely: D, d, F, G, H, i, j, l, M, n, Y, xg, xkY (and escape characters),
161     * and only dates when MediaWiki existed, let's say 2000 onwards (Thai dates before 1941 are
162     * complicated).
163     *
164     * @param string $contLangVariant Content language variant
165     * @param string $format Date format
166     * @param string $digitsRegexp Regular expression matching a single localised digit, e.g. '[0-9]'
167     * @param array $tzAbbrs Associative array mapping localised timezone abbreviations to
168     *   IANA abbreviations, for the local timezone, e.g. [ 'EDT' => 'EDT', 'EST' => 'EST' ]
169     * @return string Regular expression
170     */
171    private function getTimestampRegexp(
172        string $contLangVariant, string $format, string $digitsRegexp, array $tzAbbrs
173    ): string {
174        $formatLength = strlen( $format );
175        $s = '';
176        $raw = false;
177        // Adapted from Language::sprintfDate()
178        for ( $p = 0; $p < $formatLength; $p++ ) {
179            $num = false;
180            $code = $format[ $p ];
181            if ( $code === 'x' && $p < $formatLength - 1 ) {
182                $code .= $format[++$p];
183            }
184            if ( $code === 'xk' && $p < $formatLength - 1 ) {
185                $code .= $format[++$p];
186            }
187
188            switch ( $code ) {
189                case 'xx':
190                    $s .= 'x';
191                    break;
192                case 'xg':
193                    $s .= static::regexpAlternateGroup(
194                        $this->getMessages( $contLangVariant, Language::MONTH_GENITIVE_MESSAGES )
195                    );
196                    break;
197                case 'xn':
198                    $raw = true;
199                    break;
200                case 'd':
201                    $num = '2';
202                    break;
203                case 'D':
204                    $s .= static::regexpAlternateGroup(
205                        $this->getMessages( $contLangVariant, Language::WEEKDAY_ABBREVIATED_MESSAGES )
206                    );
207                    break;
208                case 'j':
209                    $num = '1,2';
210                    break;
211                case 'l':
212                    $s .= static::regexpAlternateGroup(
213                        $this->getMessages( $contLangVariant, Language::WEEKDAY_MESSAGES )
214                    );
215                    break;
216                case 'F':
217                    $s .= static::regexpAlternateGroup(
218                        $this->getMessages( $contLangVariant, Language::MONTH_MESSAGES )
219                    );
220                    break;
221                case 'M':
222                    $s .= static::regexpAlternateGroup(
223                        $this->getMessages( $contLangVariant, Language::MONTH_ABBREVIATED_MESSAGES )
224                    );
225                    break;
226                case 'm':
227                    $num = '2';
228                    break;
229                case 'n':
230                    $num = '1,2';
231                    break;
232                case 'Y':
233                    $num = '4';
234                    break;
235                case 'xkY':
236                    $num = '4';
237                    break;
238                case 'G':
239                    $num = '1,2';
240                    break;
241                case 'H':
242                    $num = '2';
243                    break;
244                case 'i':
245                    $num = '2';
246                    break;
247                case 's':
248                    $num = '2';
249                    break;
250                case '\\':
251                    // Backslash escaping
252                    if ( $p < $formatLength - 1 ) {
253                        $s .= preg_quote( $format[++$p], '/' );
254                    } else {
255                        $s .= preg_quote( '\\', '/' );
256                    }
257                    break;
258                case '"':
259                    // Quoted literal
260                    if ( $p < $formatLength - 1 ) {
261                        $endQuote = strpos( $format, '"', $p + 1 );
262                        if ( $endQuote === false ) {
263                            // No terminating quote, assume literal "
264                            $s .= '"';
265                        } else {
266                            $s .= preg_quote( substr( $format, $p + 1, $endQuote - $p - 1 ), '/' );
267                            $p = $endQuote;
268                        }
269                    } else {
270                        // Quote at end of string, assume literal "
271                        $s .= '"';
272                    }
273                    break;
274                default:
275                    // Copy whole characters together, instead of single bytes
276                    $char = mb_substr( mb_strcut( $format, $p, 4 ), 0, 1 );
277                    $s .= preg_quote( $char, '/' );
278                    $p += strlen( $char ) - 1;
279            }
280            if ( $num !== false ) {
281                if ( $raw ) {
282                    $s .= '([0-9]{' . $num . '})';
283                    $raw = false;
284                } else {
285                    $s .= '(' . $digitsRegexp . '{' . $num . '})';
286                }
287            }
288            // Ignore some invisible Unicode characters that often sneak into copy-pasted timestamps (T308448)
289            $s .= '[\\x{200E}\\x{200F}]?';
290        }
291
292        $tzRegexp = static::regexpAlternateGroup( array_keys( $tzAbbrs ) );
293
294        // Hard-coded parentheses and space like in Parser::pstPass2
295        // Ignore some invisible Unicode characters that often sneak into copy-pasted timestamps (T245784)
296        // \uNNNN syntax can only be used from PHP 7.3
297        return '/' . $s . ' [\\x{200E}\\x{200F}]?\\(' . $tzRegexp . '\\)/u';
298    }
299
300    /**
301     * Get a function that parses timestamps generated using the given date format, based on the result
302     * of matching the regexp returned by getTimestampRegexp()
303     *
304     * @param string $contLangVariant Content language variant
305     * @param string $format Date format, as used by MediaWiki
306     * @param array<int,string>|null $digits Localised digits from 0 to 9, e.g. `[ '0', '1', ..., '9' ]`
307     * @param string $localTimezone Local timezone IANA name, e.g. `America/New_York`
308     * @param array $tzAbbrs Map of localised timezone abbreviations to IANA abbreviations
309     *   for the local timezone, e.g. [ 'EDT' => 'EDT', 'EST' => 'EST' ]
310     * @return callable Parser function
311     */
312    private function getTimestampParser(
313        string $contLangVariant, string $format, ?array $digits, string $localTimezone, array $tzAbbrs
314    ): callable {
315        $untransformDigits = static function ( string $text ) use ( $digits ): int {
316            return (int)( $digits ? strtr( $text, array_flip( $digits ) ) : $text );
317        };
318
319        $formatLength = strlen( $format );
320        $matchingGroups = [];
321        for ( $p = 0; $p < $formatLength; $p++ ) {
322            $code = $format[$p];
323            if ( $code === 'x' && $p < $formatLength - 1 ) {
324                $code .= $format[++$p];
325            }
326            if ( $code === 'xk' && $p < $formatLength - 1 ) {
327                $code .= $format[++$p];
328            }
329
330            switch ( $code ) {
331                case 'xx':
332                case 'xn':
333                    break;
334                case 'xg':
335                case 'd':
336                case 'j':
337                case 'D':
338                case 'l':
339                case 'F':
340                case 'M':
341                case 'm':
342                case 'n':
343                case 'Y':
344                case 'xkY':
345                case 'G':
346                case 'H':
347                case 'i':
348                case 's':
349                    $matchingGroups[] = $code;
350                    break;
351                case '\\':
352                    // Backslash escaping
353                    if ( $p < $formatLength - 1 ) {
354                        $p++;
355                    }
356                    break;
357                case '"':
358                    // Quoted literal
359                    if ( $p < $formatLength - 1 ) {
360                        $endQuote = strpos( $format, '"', $p + 1 );
361                        if ( $endQuote !== false ) {
362                            $p = $endQuote;
363                        }
364                    }
365                    break;
366                default:
367                    break;
368            }
369        }
370
371        return function ( array $match ) use (
372            $matchingGroups, $untransformDigits, $localTimezone, $tzAbbrs, $contLangVariant
373        ) {
374            if ( is_array( $match[0] ) ) {
375                // Strip PREG_OFFSET_CAPTURE data
376                unset( $match['offset'] );
377                $match = array_map( static function ( array $tuple ) {
378                    return $tuple[0];
379                }, $match );
380            }
381            $year = 0;
382            $monthIdx = 0;
383            $day = 0;
384            $hour = 0;
385            $minute = 0;
386            foreach ( $matchingGroups as $i => $code ) {
387                $text = $match[$i + 1];
388                switch ( $code ) {
389                    case 'xg':
390                        $monthIdx = array_search(
391                            $text,
392                            $this->getMessages( $contLangVariant, Language::MONTH_GENITIVE_MESSAGES ),
393                            true
394                        );
395                        break;
396                    case 'd':
397                    case 'j':
398                        $day = $untransformDigits( $text );
399                        break;
400                    case 'D':
401                    case 'l':
402                        // Day of the week - unused
403                        break;
404                    case 'F':
405                        $monthIdx = array_search(
406                            $text,
407                            $this->getMessages( $contLangVariant, Language::MONTH_MESSAGES ),
408                            true
409                        );
410                        break;
411                    case 'M':
412                        $monthIdx = array_search(
413                            $text,
414                            $this->getMessages( $contLangVariant, Language::MONTH_ABBREVIATED_MESSAGES ),
415                            true
416                        );
417                        break;
418                    case 'm':
419                    case 'n':
420                        $monthIdx = $untransformDigits( $text ) - 1;
421                        break;
422                    case 'Y':
423                        $year = $untransformDigits( $text );
424                        break;
425                    case 'xkY':
426                        // Thai year
427                        $year = $untransformDigits( $text ) - 543;
428                        break;
429                    case 'G':
430                    case 'H':
431                        $hour = $untransformDigits( $text );
432                        break;
433                    case 'i':
434                        $minute = $untransformDigits( $text );
435                        break;
436                    case 's':
437                        // Seconds - unused, because most timestamp formats omit them
438                        break;
439                    default:
440                        throw new LogicException( 'Not implemented' );
441                }
442            }
443
444            // The last matching group is the timezone abbreviation
445            $tzAbbr = $tzAbbrs[ end( $match ) ];
446
447            // Most of the time, the timezone abbreviation is not necessary to parse the date, since we
448            // can assume all times are in the wiki's local timezone.
449            $date = new DateTime();
450            // setTimezone must be called before setDate/setTime
451            $date->setTimezone( new DateTimeZone( $localTimezone ) );
452            $date->setDate( $year, $monthIdx + 1, $day );
453            $date->setTime( $hour, $minute, 0 );
454
455            // But during the "fall back" at the end of DST, some times will happen twice.
456            // Since the timezone abbreviation disambiguates the DST/non-DST times, we can detect
457            // when PHP chose the wrong one, and then try the other one. It appears that PHP always
458            // uses the later (non-DST) hour, but that behavior isn't documented, so we account for both.
459            $dateWarning = null;
460            if ( $date->format( 'T' ) !== $tzAbbr ) {
461                $altDate = clone $date;
462                if ( $date->format( 'I' ) ) {
463                    // Parsed time is DST, try non-DST by advancing one hour
464                    $altDate->add( new DateInterval( 'PT1H' ) );
465                } else {
466                    // Parsed time is non-DST, try DST by going back one hour
467                    $altDate->sub( new DateInterval( 'PT1H' ) );
468                }
469                if ( $altDate->format( 'T' ) === $tzAbbr ) {
470                    $date = $altDate;
471                    $dateWarning = 'Timestamp has timezone abbreviation for the wrong time';
472                } else {
473                    $dateWarning = 'Ambiguous time at DST switchover was parsed';
474                }
475            }
476
477            // Now set the timezone back to UTC for formatting
478            $date->setTimezone( new DateTimeZone( 'UTC' ) );
479            $date = DateTimeImmutable::createFromMutable( $date );
480
481            // We require the date to be compatible with our libraries, for example zero or negative years (T352455)
482            // In PHP we need to check with MWTimestamp.
483            // In JS we need to check with Moment.
484            try {
485                // @phan-suppress-next-line PhanNoopNew
486                new MWTimestamp( $date->format( 'c' ) );
487            } catch ( TimestampException ) {
488                return null;
489            }
490
491            return [
492                'date' => $date,
493                'warning' => $dateWarning,
494            ];
495        };
496    }
497
498    /**
499     * Get a regexp that matches timestamps in the local date format, for each language variant.
500     *
501     * This calls getTimestampRegexp() with predefined data for the current wiki.
502     *
503     * @return string[] Regular expressions
504     */
505    public function getLocalTimestampRegexps(): array {
506        $langConv = $this->languageConverterFactory->getLanguageConverter( $this->language );
507        return array_map( function ( $contLangVariant ) {
508            return $this->getTimestampRegexp(
509                $contLangVariant,
510                $this->dateFormat[$contLangVariant],
511                '[' . implode( '', $this->digits[$contLangVariant] ) . ']',
512                $this->timezones[$contLangVariant]
513            );
514        }, $langConv->getVariants() );
515    }
516
517    /**
518     * Get a function that parses timestamps in the local date format, for each language variant,
519     * based on the result of matching the regexp returned by getLocalTimestampRegexp().
520     *
521     * This calls getTimestampParser() with predefined data for the current wiki.
522     *
523     * @return callable[] Parser functions
524     */
525    private function getLocalTimestampParsers(): array {
526        $langConv = $this->languageConverterFactory->getLanguageConverter( $this->language );
527        return array_map( function ( $contLangVariant ) {
528            return $this->getTimestampParser(
529                $contLangVariant,
530                $this->dateFormat[$contLangVariant],
531                $this->digits[$contLangVariant],
532                $this->localTimezone,
533                $this->timezones[$contLangVariant]
534            );
535        }, $langConv->getVariants() );
536    }
537
538    /**
539     * Given a link node (`<a>`), if it's a link to a user-related page, return their username.
540     *
541     * @return array|null Array, or null:
542     * - string 'username' Username
543     * - string|null 'displayName' Display name (link text if link target was in the user namespace)
544     */
545    private function getUsernameFromLink( Element $link ): ?array {
546        // Selflink: use title of current page
547        if ( DOMUtils::hasClass( $link, 'mw-selflink' ) ) {
548            $title = $this->title;
549        } else {
550            $titleString = CommentUtils::getTitleFromUrl( $link->getAttribute( 'href' ) ?? '', $this->config ) ?? '';
551            // Performance optimization, skip strings that obviously don't contain a namespace
552            if ( $titleString === '' || !str_contains( $titleString, ':' ) ) {
553                return null;
554            }
555            $title = $this->parseTitle( $titleString );
556            if ( !$title ) {
557                return null;
558            }
559        }
560
561        $username = null;
562        $displayName = null;
563        $mainText = $title->getText();
564
565        if ( $title->inNamespace( NS_USER ) || $title->inNamespace( NS_USER_TALK ) ) {
566            $username = $mainText;
567            if ( str_contains( $username, '/' ) ) {
568                return null;
569            }
570            if ( $title->inNamespace( NS_USER ) ) {
571                // Use regex trim for consistency with JS implementation
572                $text = preg_replace( [ '/^[\s]+/u', '/[\s]+$/u' ], '', $link->textContent ?? '' );
573                // Record the display name if it has been customised beyond changing case
574                if ( $text && mb_strtolower( $text ) !== mb_strtolower( $username ) ) {
575                    $displayName = $text;
576                }
577            }
578        } elseif ( $title->inNamespace( NS_SPECIAL ) ) {
579            $parts = explode( '/', $mainText );
580            if ( count( $parts ) === 2 && $parts[0] === $this->specialContributionsName ) {
581                // Normalize the username: users may link to their contributions with an unnormalized name
582                $userpage = $this->titleParser->makeTitleValueSafe( NS_USER, $parts[1] );
583                if ( !$userpage ) {
584                    return null;
585                }
586                $username = $userpage->getText();
587            }
588        }
589        if ( $username === null ) {
590            return null;
591        }
592        if ( IPUtils::isIPv6( $username ) ) {
593            // Bot-generated links "Preceding unsigned comment added by" have non-standard case
594            $username = strtoupper( $username );
595        }
596        return [
597            'username' => $username,
598            'displayName' => $displayName,
599        ];
600    }
601
602    /**
603     * Find a user signature preceding a timestamp.
604     *
605     * The signature includes the timestamp node.
606     *
607     * A signature must contain at least one link to the user's userpage, discussion page or
608     * contributions (and may contain other links). The link may be nested in other elements.
609     *
610     * @param Text $timestampNode
611     * @param Node|null $until Node to stop searching at
612     * @return array Result, an associative array with the following keys:
613     *   - Node[] `nodes` Sibling nodes comprising the signature, in reverse order (with
614     *     $timestampNode or its parent node as the first element)
615     *   - string|null `username` Username, null for unsigned comments
616     */
617    private function findSignature( Text $timestampNode, ?Node $until = null ): array {
618        $sigUsername = null;
619        $sigDisplayName = null;
620        $length = 0;
621        $lastLinkNode = $timestampNode;
622
623        CommentUtils::linearWalkBackwards(
624            $timestampNode,
625            function ( string $event, Node $node ) use (
626                &$sigUsername, &$sigDisplayName, &$lastLinkNode, &$length,
627                $until, $timestampNode
628            ) {
629                if ( $event === 'enter' && $node === $until ) {
630                    return true;
631                }
632                if ( $length >= static::SIGNATURE_SCAN_LIMIT ) {
633                    return true;
634                }
635                if ( CommentUtils::isBlockElement( $node ) ) {
636                    // Don't allow reaching into preceding paragraphs
637                    return true;
638                }
639
640                if ( $event === 'leave' && $node !== $timestampNode ) {
641                    $length += $node instanceof Text ?
642                        mb_strlen( CommentUtils::htmlTrim( $node->textContent ?? '' ) ) : 0;
643                }
644
645                // Find the closest link before timestamp that links to the user's user page.
646                //
647                // Support timestamps being linked to the diff introducing the comment:
648                // if the timestamp node is the only child of a link node, use the link node instead
649                //
650                // Handle links nested in formatting elements.
651                if ( $event === 'leave' && $node instanceof Element && strtolower( $node->tagName ) === 'a' ) {
652                    $classList = DOMCompat::getClassList( $node );
653                    // Generated timestamp links sometimes look like username links (e.g. on user talk pages)
654                    // so ignore these.
655                    if ( !$classList->contains( 'ext-discussiontools-init-timestamplink' ) ) {
656                        $user = $this->getUsernameFromLink( $node );
657                        if ( $user ) {
658                            // Accept the first link to the user namespace, then only accept links to that user
659                            $sigUsername ??= $user['username'];
660                            if ( $user['username'] === $sigUsername ) {
661                                $lastLinkNode = $node;
662                                if ( $user['displayName'] ) {
663                                    $sigDisplayName = $user['displayName'];
664                                }
665                            }
666                        }
667                        // Keep looking if a node with links wasn't a link to a user page
668                        // "Doc James (talk Â· contribs Â· email)"
669                    }
670                }
671            }
672        );
673
674        $range = new ImmutableRange(
675            $lastLinkNode->parentNode,
676            CommentUtils::childIndexOf( $lastLinkNode ),
677            $timestampNode->parentNode,
678            CommentUtils::childIndexOf( $timestampNode ) + 1
679        );
680
681        // Expand the range so that it covers sibling nodes.
682        // This will include any wrapping formatting elements as part of the signature.
683        //
684        // Helpful accidental feature: users whose signature is not detected in full (due to
685        // text formatting) can just wrap it in a <span> to fix that.
686        // "Ten Pound Hammer â€¢ (What did I screw up now?)"
687        // "« Saper // dyskusja Â»"
688        //
689        // TODO Not sure if this is actually good, might be better to just use the range...
690        $sigNodes = array_reverse( CommentUtils::getCoveredSiblings( $range ) );
691
692        return [
693            'nodes' => $sigNodes,
694            'username' => $sigUsername,
695            'displayName' => $sigDisplayName,
696        ];
697    }
698
699    /**
700     * Callback for TreeWalker that will skip over nodes where we don't want to detect
701     * comments (or section headings).
702     *
703     * @return int Appropriate NodeFilter constant
704     */
705    public static function acceptOnlyNodesAllowingComments( Node $node ): int {
706        if ( $node instanceof Element ) {
707            $tagName = strtolower( $node->tagName );
708            // The table of contents has a heading that gets erroneously detected as a section
709            if ( $node->getAttribute( 'id' ) === 'toc' ) {
710                return NodeFilter::FILTER_REJECT;
711            }
712            // Don't detect comments within quotes (T275881)
713            if (
714                $tagName === 'blockquote' ||
715                $tagName === 'cite' ||
716                $tagName === 'q'
717            ) {
718                return NodeFilter::FILTER_REJECT;
719            }
720            // Don't attempt to parse blocks marked 'mw-notalk'
721            if ( DOMUtils::hasClass( $node, 'mw-notalk' ) ) {
722                return NodeFilter::FILTER_REJECT;
723            }
724            // Don't detect comments within references. We can't add replies to them without bungling up
725            // the structure in some cases (T301213), and you're not supposed to do that anyway…
726            if (
727                // <ol class="references"> is the only reliably consistent thing between the two parsers
728                $tagName === 'ol' &&
729                DOMUtils::hasClass( $node, 'references' )
730            ) {
731                return NodeFilter::FILTER_REJECT;
732            }
733        }
734        $parentNode = $node->parentNode;
735        // Don't detect comments within headings (but don't reject the headings themselves)
736        if ( $parentNode instanceof Element && preg_match( '/^h([1-6])$/i', $parentNode->tagName ) ) {
737            return NodeFilter::FILTER_REJECT;
738        }
739        return NodeFilter::FILTER_ACCEPT;
740    }
741
742    /**
743     * Convert a byte offset within a text node to a unicode codepoint offset
744     *
745     * @param Text $node Text node
746     * @param int $byteOffset Byte offset
747     * @return int Codepoint offset
748     */
749    private static function getCodepointOffset( Text $node, int $byteOffset ): int {
750        return mb_strlen( substr( $node->nodeValue ?? '', 0, $byteOffset ) );
751    }
752
753    /**
754     * Find a timestamps in a given text node
755     *
756     * @param Text $node
757     * @param string[] $timestampRegexps
758     * @return array|null Array with the following keys:
759     *   - int 'offset' Length of extra text preceding the node that was used for matching (in bytes)
760     *   - int 'parserIndex' Which of the regexps matched
761     *   - array 'matchData' Regexp match data, which specifies the location of the match,
762     *     and which can be parsed using getLocalTimestampParsers() (offsets are in bytes)
763     *   - ImmutableRange 'range' Range covering the timestamp
764     */
765    public function findTimestamp( Text $node, array $timestampRegexps ): ?array {
766        $nodeText = '';
767        $offset = 0;
768        // Searched nodes (reverse order)
769        $nodes = [];
770
771        while ( $node ) {
772            $nodeText = $node->nodeValue . $nodeText;
773            $nodes[] = $node;
774
775            // In Parsoid HTML, entities are represented as a 'mw:Entity' node, rather than normal HTML
776            // entities. On Arabic Wikipedia, the "UTC" timezone name contains some non-breaking spaces,
777            // which apparently are often turned into &nbsp; entities by buggy editing tools. To handle
778            // this, we must piece together the text, so that our regexp can match those timestamps.
779            if (
780                ( $previousSibling = $node->previousSibling ) &&
781                $previousSibling instanceof Element &&
782                $previousSibling->getAttribute( 'typeof' ) === 'mw:Entity'
783            ) {
784                $nodeText = $previousSibling->firstChild->nodeValue . $nodeText;
785                $offset += strlen( $previousSibling->firstChild->nodeValue ?? '' );
786                $nodes[] = $previousSibling->firstChild;
787
788                // If the entity is preceded by more text, do this again
789                if (
790                    $previousSibling->previousSibling &&
791                    $previousSibling->previousSibling instanceof Text
792                ) {
793                    $offset += strlen( $previousSibling->previousSibling->nodeValue ?? '' );
794                    $node = $previousSibling->previousSibling;
795                } else {
796                    $node = null;
797                }
798            } else {
799                $node = null;
800            }
801        }
802
803        foreach ( $timestampRegexps as $i => $timestampRegexp ) {
804            $matchData = null;
805            // Allows us to mimic match.index in #getComments
806            if ( preg_match( $timestampRegexp, $nodeText, $matchData, PREG_OFFSET_CAPTURE ) ) {
807                $timestampLength = strlen( $matchData[0][0] );
808                // Bytes at the end of the last node which aren't part of the match
809                $tailLength = strlen( $nodeText ) - $timestampLength - $matchData[0][1];
810                // We are moving right to left, but we start to the right of the end of
811                // the timestamp if there is trailing garbage, so that is a negative offset.
812                $count = -$tailLength;
813                $endNode = $nodes[0];
814                $endOffset = strlen( $endNode->nodeValue ?? '' ) - $tailLength;
815
816                foreach ( $nodes as $n ) {
817                    $count += strlen( $n->nodeValue ?? '' );
818                    // If we have counted to beyond the start of the timestamp, we are in the
819                    // start node of the timestamp
820                    if ( $count >= $timestampLength ) {
821                        $startNode = $n;
822                        // Offset is how much we overshot the start by
823                        $startOffset = $count - $timestampLength;
824                        break;
825                    }
826                }
827                Assert::precondition( $endNode instanceof Node, 'endNode of timestamp is a Node' );
828                Assert::precondition( $startNode instanceof Node, 'startNode of timestamp range found' );
829                Assert::precondition( is_int( $startOffset ), 'startOffset of timestamp range found' );
830
831                $startOffset = static::getCodepointOffset( $startNode, $startOffset );
832                $endOffset = static::getCodepointOffset( $endNode, $endOffset );
833
834                $range = new ImmutableRange( $startNode, $startOffset, $endNode, $endOffset );
835
836                return [
837                    'matchData' => $matchData,
838                    // Bytes at the start of the first node which aren't part of the match
839                    // TODO: Remove this and use 'range' instead
840                    'offset' => $offset,
841                    'range' => $range,
842                    'parserIndex' => $i,
843                ];
844            }
845        }
846        return null;
847    }
848
849    /**
850     * @param Node[] $sigNodes
851     * @param array $match
852     * @param Text $node
853     */
854    private function adjustSigRange( array $sigNodes, array $match, Text $node ): ImmutableRange {
855        $firstSigNode = end( $sigNodes );
856        $lastSigNode = $sigNodes[0];
857
858        // TODO Document why this needs to be so complicated
859        $lastSigNodeOffsetByteOffset =
860            $match['matchData'][0][1] + strlen( $match['matchData'][0][0] ) - $match['offset'];
861        $lastSigNodeOffset = $lastSigNode === $node ?
862            static::getCodepointOffset( $node, $lastSigNodeOffsetByteOffset ) :
863            CommentUtils::childIndexOf( $lastSigNode ) + 1;
864        $sigRange = new ImmutableRange(
865            $firstSigNode->parentNode,
866            CommentUtils::childIndexOf( $firstSigNode ),
867            $lastSigNode === $node ? $node : $lastSigNode->parentNode,
868            $lastSigNodeOffset
869        );
870
871        return $sigRange;
872    }
873
874    private function buildThreadItems(): ContentThreadItemSet {
875        $result = new ContentThreadItemSet();
876
877        $timestampRegexps = $this->getLocalTimestampRegexps();
878        $dfParsers = $this->getLocalTimestampParsers();
879
880        $curCommentEnd = null;
881
882        $treeWalker = new TreeWalker(
883            $this->rootNode,
884            NodeFilter::SHOW_ELEMENT | NodeFilter::SHOW_TEXT,
885            [ static::class, 'acceptOnlyNodesAllowingComments' ]
886        );
887        while ( $node = $treeWalker->nextNode() ) {
888            if ( $node instanceof Element && preg_match( '/^h([1-6])$/i', $node->tagName, $match ) ) {
889                $headingNode = CommentUtils::getHeadlineNode( $node );
890                $range = new ImmutableRange(
891                    $headingNode, 0, $headingNode, $headingNode->childNodes->length
892                );
893                $transcludedFrom = $this->computeTranscludedFrom( $range );
894                $curComment = new ContentHeadingItem( $range, $transcludedFrom, (int)( $match[ 1 ] ) );
895                $curComment->setRootNode( $this->rootNode );
896                $result->addThreadItem( $curComment );
897                $curCommentEnd = $node;
898            } elseif ( $node instanceof Text && ( $match = $this->findTimestamp( $node, $timestampRegexps ) ) ) {
899                $warnings = [];
900                $foundSignature = $this->findSignature( $node, $curCommentEnd );
901                $author = $foundSignature['username'];
902
903                if ( $author === null ) {
904                    // Ignore timestamps for which we couldn't find a signature. It's probably not a real
905                    // comment, but just a false match due to a copypasted timestamp.
906                    continue;
907                }
908
909                $sigRanges = [];
910                $timestampRanges = [];
911
912                $sigRanges[] = $this->adjustSigRange( $foundSignature['nodes'], $match, $node );
913                $timestampRanges[] = $match['range'];
914
915                // Everything from the last comment up to here is the next comment
916                $startNode = $this->nextInterestingLeafNode( $curCommentEnd );
917                $endNode = $foundSignature['nodes'][0];
918
919                // Skip to the end of the "paragraph". This only looks at tag names and can be fooled by CSS, but
920                // avoiding that would be more difficult and slower.
921                //
922                // If this skips over another potential signature, also skip it in the main TreeWalker loop, to
923                // avoid generating multiple comments when there is more than one signature on a single "line".
924                // Often this is done when someone edits their comment later and wants to add a note about that.
925                // (Or when another person corrects a typo, or strikes out a comment, etc.) Multiple comments
926                // within one paragraph/list-item result in a confusing double "Reply" button, and we also have
927                // no way to indicate which one you're replying to (this might matter in the future for
928                // notifications or something).
929                CommentUtils::linearWalk(
930                    $endNode,
931                    function ( string $event, Node $n ) use (
932                        &$endNode, &$sigRanges, &$timestampRanges,
933                        $treeWalker, $timestampRegexps, $node
934                    ) {
935                        if ( CommentUtils::isBlockElement( $n ) || CommentUtils::isCommentSeparator( $n ) ) {
936                            // Stop when entering or leaving a block node
937                            return true;
938                        }
939                        if (
940                            $event === 'leave' &&
941                            $n instanceof Text && $n !== $node &&
942                            ( $match2 = $this->findTimestamp( $n, $timestampRegexps ) )
943                        ) {
944                            // If this skips over another potential signature, also skip it in the main TreeWalker loop
945                            $treeWalker->currentNode = $n;
946                            // â€¦and add it as another signature to this comment (regardless of the author and timestamp)
947                            $foundSignature2 = $this->findSignature( $n, $node );
948                            if ( $foundSignature2['username'] !== null ) {
949                                $sigRanges[] = $this->adjustSigRange( $foundSignature2['nodes'], $match2, $n );
950                                $timestampRanges[] = $match2['range'];
951                            }
952                        }
953                        if ( $event === 'leave' ) {
954                            // Take the last complete node which we skipped past
955                            $endNode = $n;
956                        }
957                    }
958                );
959
960                $length = ( $endNode instanceof Text ) ?
961                    mb_strlen( rtrim( $endNode->nodeValue ?? '', "\t\n\f\r " ) ) :
962                    // PHP bug: childNodes can be null for comment nodes
963                    // (it should always be a NodeList, even if the node can't have children)
964                    ( $endNode->childNodes ? $endNode->childNodes->length : 0 );
965                $range = new ImmutableRange(
966                    $startNode->parentNode,
967                    CommentUtils::childIndexOf( $startNode ),
968                    $endNode,
969                    $length
970                );
971                $transcludedFrom = $this->computeTranscludedFrom( $range );
972
973                $startLevel = CommentUtils::getIndentLevel( $startNode, $this->rootNode ) + 1;
974                $endLevel = CommentUtils::getIndentLevel( $node, $this->rootNode ) + 1;
975                if ( $startLevel !== $endLevel ) {
976                    $warnings[] = 'Comment starts and ends with different indentation';
977                }
978                // Should this use the indent level of $startNode or $node?
979                $level = min( $startLevel, $endLevel );
980
981                $parserResult = $dfParsers[ $match['parserIndex'] ]( $match['matchData'] );
982                if ( !$parserResult ) {
983                    continue;
984                }
985                [ 'date' => $dateTime, 'warning' => $dateWarning ] = $parserResult;
986
987                if ( $dateWarning ) {
988                    $warnings[] = $dateWarning;
989                }
990
991                $curComment = new ContentCommentItem(
992                    $level,
993                    $range,
994                    $transcludedFrom,
995                    $sigRanges,
996                    $timestampRanges,
997                    $dateTime,
998                    $author,
999                    $foundSignature['displayName']
1000                );
1001                $curComment->setRootNode( $this->rootNode );
1002                if ( $warnings ) {
1003                    $curComment->addWarnings( $warnings );
1004                }
1005                if ( $result->isEmpty() ) {
1006                    // Add a fake placeholder heading if there are any comments in the 0th section
1007                    // (before the first real heading)
1008                    $range = new ImmutableRange( $this->rootNode, 0, $this->rootNode, 0 );
1009                    $fakeHeading = new ContentHeadingItem( $range, false, null );
1010                    $fakeHeading->setRootNode( $this->rootNode );
1011                    $result->addThreadItem( $fakeHeading );
1012                }
1013                $result->addThreadItem( $curComment );
1014                $curCommentEnd = $curComment->getRange()->endContainer;
1015            }
1016        }
1017
1018        return $result;
1019    }
1020
1021    /**
1022     * Get the name of the page from which this thread item is transcluded (if any). Replies to
1023     * transcluded items must be posted on that page, instead of the current one.
1024     *
1025     * This is tricky, because we don't want to mark items as trancluded when they're just using a
1026     * template (e.g. {{ping|…}} or a non-substituted signature template). Sometimes the whole comment
1027     * can be template-generated (e.g. when using some wrapper templates), but as long as a reply can
1028     * be added outside of that template, we should not treat it as transcluded.
1029     *
1030     * The start/end boundary points of comment ranges and Parsoid transclusion ranges don't line up
1031     * exactly, even when to a human it's obvious that they cover the same content, making this more
1032     * complicated.
1033     *
1034     * @return string|bool `false` if this item is not transcluded. A string if it's transcluded
1035     *   from a single page (the page title, in text form with spaces). `true` if it's transcluded, but
1036     *   we can't determine the source.
1037     */
1038    public function computeTranscludedFrom( ImmutableRange $commentRange ) {
1039        // Collapsed ranges should otherwise be impossible, but they're not (T299583)
1040        // TODO: See if we can fix the root cause, and remove this?
1041        if ( $commentRange->collapsed ) {
1042            return false;
1043        }
1044
1045        // General approach:
1046        //
1047        // Compare the comment range to each transclusion range on the page, and if it overlaps any of
1048        // them, examine the overlap. There are a few cases:
1049        //
1050        // * Comment and transclusion do not overlap:
1051        //   â†’ Not transcluded.
1052        // * Comment contains the transclusion:
1053        //   â†’ Not transcluded (just a template).
1054        // * Comment is contained within the transclusion:
1055        //   â†’ Transcluded, we can determine the source page (unless it's a complex transclusion).
1056        // * Comment and transclusion overlap partially:
1057        //   â†’ Transcluded, but we can't determine the source page.
1058        // * Comment (almost) exactly matches the transclusion:
1059        //   â†’ Maybe transcluded (it could be that the source page only contains that single comment),
1060        //     maybe not transcluded (it could be a wrapper template that covers a single comment).
1061        //     This is very sad, and we decide based on the namespace.
1062        //
1063        // Most transclusion ranges on the page trivially fall in the "do not overlap" or "contains"
1064        // cases, and we only have to carefully examine the two transclusion ranges that contain the
1065        // first and last node of the comment range.
1066        //
1067        // To check for almost exact matches, we walk between the relevant boundary points, and if we
1068        // only find uninteresting nodes (that would be ignored when detecting comments), we treat them
1069        // like exact matches.
1070
1071        $startTransclNode = CommentUtils::getTranscludedFromElement(
1072            CommentUtils::getRangeFirstNode( $commentRange )
1073        );
1074        $endTransclNode = CommentUtils::getTranscludedFromElement(
1075            CommentUtils::getRangeLastNode( $commentRange )
1076        );
1077
1078        // We only have to examine the two transclusion ranges that contain the first/last node of the
1079        // comment range (if they exist). Ignore ranges outside the comment or in the middle of it.
1080        $transclNodes = [];
1081        if ( $startTransclNode ) {
1082            $transclNodes[] = $startTransclNode;
1083        }
1084        if ( $endTransclNode && $endTransclNode !== $startTransclNode ) {
1085            $transclNodes[] = $endTransclNode;
1086        }
1087
1088        foreach ( $transclNodes as $transclNode ) {
1089            $transclRange = static::getTransclusionRange( $transclNode );
1090            $compared = CommentUtils::compareRanges( $commentRange, $transclRange );
1091            $transclTitles = $this->getTransclusionTitles( $transclNode );
1092            $simpleTransclTitle = count( $transclTitles ) === 1 && $transclTitles[0] !== null ?
1093                $this->parseTitle( $transclTitles[0] ) : null;
1094
1095            switch ( $compared ) {
1096                case 'equal':
1097                    // Comment (almost) exactly matches the transclusion
1098                    if ( $simpleTransclTitle === null ) {
1099                        // Allow replying to some accidental complex transclusions consisting of only templates
1100                        // and wikitext (T313093)
1101                        if ( count( $transclTitles ) > 1 ) {
1102                            foreach ( $transclTitles as $transclTitleString ) {
1103                                if ( $transclTitleString !== null ) {
1104                                    $transclTitle = $this->parseTitle( $transclTitleString );
1105                                    if ( $transclTitle && !$transclTitle->inNamespace( NS_TEMPLATE ) ) {
1106                                        return true;
1107                                    }
1108                                }
1109                            }
1110                            // Continue examining the other ranges.
1111                            break;
1112                        }
1113                        // Multi-template transclusion, or a parser function call, or template-affected wikitext outside
1114                        // of a template call, or a mix of the above
1115                        return true;
1116
1117                    } elseif ( $simpleTransclTitle->inNamespace( NS_TEMPLATE ) ) {
1118                        // Is that a subpage transclusion with a single comment, or a wrapper template
1119                        // transclusion on this page? We don't know, but let's guess based on the namespace.
1120                        // (T289873)
1121                        // Continue examining the other ranges.
1122                        break;
1123                    } elseif ( !$this->titleCanExist( $simpleTransclTitle ) ) {
1124                        // Special page transclusion (T344622) or something else weird. Don't return the title,
1125                        // since it's useless for replying, and can't be stored in the permalink database.
1126                        return true;
1127                    } else {
1128                        Assert::precondition( $transclTitles[0] !== null, "Simple transclusion found" );
1129                        return strtr( $transclTitles[0], '_', ' ' );
1130                    }
1131
1132                case 'contains':
1133                    // Comment contains the transclusion
1134
1135                    // If the entire transclusion is contained within the comment range, that's just a
1136                    // template. This is the same as a transclusion in the middle of the comment, which we
1137                    // ignored earlier, it just takes us longer to get here in this case.
1138
1139                    // Continue examining the other ranges.
1140                    break;
1141
1142                case 'contained':
1143                    // Comment is contained within the transclusion
1144                    if ( $simpleTransclTitle === null ) {
1145                        return true;
1146                    } elseif ( !$this->titleCanExist( $simpleTransclTitle ) ) {
1147                        // Special page transclusion (T344622) or something else weird. Don't return the title,
1148                        // since it's useless for replying, and can't be stored in the permalink database.
1149                        return true;
1150                    } else {
1151                        Assert::precondition( $transclTitles[0] !== null, "Simple transclusion found" );
1152                        return strtr( $transclTitles[0], '_', ' ' );
1153                    }
1154
1155                case 'after':
1156                case 'before':
1157                    // Comment and transclusion do not overlap
1158
1159                    // This should be impossible, because we ignored these ranges earlier.
1160                    throw new LogicException( 'Unexpected transclusion or comment range' );
1161
1162                case 'overlapstart':
1163                case 'overlapend':
1164                    // Comment and transclusion overlap partially
1165                    return true;
1166
1167                default:
1168                    throw new LogicException( 'Unexpected return value from compareRanges()' );
1169            }
1170        }
1171
1172        // If we got here, the comment range was not contained by or overlapping any of the transclusion
1173        // ranges. Comment is not transcluded.
1174        return false;
1175    }
1176
1177    private function titleCanExist( TitleValue $title ): bool {
1178        return $title->getNamespace() >= NS_MAIN &&
1179            !$title->isExternal() &&
1180            $title->getText() !== '';
1181    }
1182
1183    private function parseTitle( string $titleString ): ?TitleValue {
1184        try {
1185            return $this->titleParser->parseTitle( $titleString );
1186        } catch ( MalformedTitleException ) {
1187            return null;
1188        }
1189    }
1190
1191    /**
1192     * Return the page titles for each part of the transclusion, or nulls for each part that isn't
1193     * transcluded from another page.
1194     *
1195     * If the node represents a single-page transclusion, this will return an array containing a
1196     * single string.
1197     *
1198     * @return array<string|null>
1199     */
1200    private function getTransclusionTitles( Element $node ): array {
1201        $dataMw = json_decode( $node->getAttribute( 'data-mw' ) ?? '', true );
1202        $out = [];
1203
1204        foreach ( $dataMw['parts'] ?? [] as $part ) {
1205            if (
1206                !is_string( $part ) &&
1207                // 'href' will be unset if this is a parser function rather than a template
1208                isset( $part['template']['target']['href'] )
1209            ) {
1210                $parsoidHref = $part['template']['target']['href'];
1211                Assert::precondition( str_starts_with( $parsoidHref, './' ), 'href has valid format' );
1212                $out[] = rawurldecode( substr( $parsoidHref, 2 ) );
1213            } else {
1214                $out[] = null;
1215            }
1216        }
1217
1218        return $out;
1219    }
1220
1221    /**
1222     * Given a transclusion's first node (e.g. returned by CommentUtils::getTranscludedFromElement()),
1223     * return a range starting before the node and ending after the transclusion's last node.
1224     */
1225    private function getTransclusionRange( Element $startNode ): ImmutableRange {
1226        $endNode = $startNode;
1227        while (
1228            // Phan doesn't realize that the conditions on $nextSibling can terminate the loop
1229            // @phan-suppress-next-line PhanInfiniteLoop
1230            $endNode &&
1231            ( $nextSibling = $endNode->nextSibling ) &&
1232            $nextSibling instanceof Element &&
1233            $nextSibling->getAttribute( 'about' ) === $endNode->getAttribute( 'about' )
1234        ) {
1235            $endNode = $nextSibling;
1236        }
1237
1238        $range = new ImmutableRange(
1239            $startNode->parentNode,
1240            CommentUtils::childIndexOf( $startNode ),
1241            $endNode->parentNode,
1242            CommentUtils::childIndexOf( $endNode ) + 1
1243        );
1244
1245        return $range;
1246    }
1247
1248    /**
1249     * Truncate user generated parts of IDs so full ID always fits within a database field of length 255
1250     *
1251     * nb: Text should already have had spaces replaced with underscores by this point.
1252     *
1253     * @param string $text Text
1254     * @param bool $legacy Generate legacy ID, not needed in JS implementation
1255     * @return string Truncated text
1256     */
1257    private function truncateForId( string $text, bool $legacy = false ): string {
1258        $truncated = $this->language->truncateForDatabase( $text, 80, '' );
1259        if ( !$legacy ) {
1260            $truncated = trim( $truncated, '_' );
1261        }
1262        return $truncated;
1263    }
1264
1265    /**
1266     * Given a thread item, return an identifier for it that is unique within the page.
1267     *
1268     * @param ContentThreadItem $threadItem
1269     * @param ContentThreadItemSet $previousItems
1270     * @param bool $legacy Generate legacy ID, not needed in JS implementation
1271     */
1272    private function computeId(
1273        ContentThreadItem $threadItem, ContentThreadItemSet $previousItems, bool $legacy = false
1274    ): string {
1275        $id = null;
1276
1277        if ( $threadItem instanceof ContentHeadingItem && $threadItem->isPlaceholderHeading() ) {
1278            // The range points to the root note, using it like below results in silly values
1279            $id = 'h-';
1280        } elseif ( $threadItem instanceof ContentHeadingItem ) {
1281            $id = 'h-' . $this->truncateForId( $threadItem->getLinkableId(), $legacy );
1282        } elseif ( $threadItem instanceof ContentCommentItem ) {
1283            $id = 'c-' . $this->truncateForId( str_replace( ' ', '_', $threadItem->getAuthor() ), $legacy ) .
1284                '-' . $threadItem->getTimestampString();
1285        } else {
1286            throw new InvalidArgumentException( 'Unknown ThreadItem type' );
1287        }
1288
1289        // If there would be multiple comments with the same ID (i.e. the user left multiple comments
1290        // in one edit, or within a minute), add the parent ID to disambiguate them.
1291        $threadItemParent = $threadItem->getParent();
1292        if ( $threadItemParent instanceof ContentHeadingItem && !$threadItemParent->isPlaceholderHeading() ) {
1293            $id .= '-' . $this->truncateForId( $threadItemParent->getLinkableId(), $legacy );
1294        } elseif ( $threadItemParent instanceof ContentCommentItem ) {
1295            $id .= '-' . $this->truncateForId( str_replace( ' ', '_', $threadItemParent->getAuthor() ), $legacy ) .
1296                '-' . $threadItemParent->getTimestampString();
1297        }
1298
1299        if ( $threadItem instanceof ContentHeadingItem ) {
1300            // To avoid old threads re-appearing on popular pages when someone uses a vague title
1301            // (e.g. dozens of threads titled "question" on [[Wikipedia:Help desk]]: https://w.wiki/fbN),
1302            // include the oldest timestamp in the thread (i.e. date the thread was started) in the
1303            // heading ID.
1304            $oldestComment = $threadItem->getOldestReply();
1305            if ( $oldestComment ) {
1306                $id .= '-' . $oldestComment->getTimestampString();
1307            }
1308        }
1309
1310        if ( $previousItems->findCommentById( $id ) ) {
1311            // Well, that's tough
1312            if ( !$legacy ) {
1313                $threadItem->addWarning( 'Duplicate comment ID' );
1314            }
1315            // Finally, disambiguate by adding sequential numbers, to allow replying to both comments
1316            $number = 1;
1317            while ( $previousItems->findCommentById( "$id-$number" ) ) {
1318                $number++;
1319            }
1320            $id = "$id-$number";
1321        }
1322
1323        return $id;
1324    }
1325
1326    /**
1327     * Given a thread item, return an identifier for it that is consistent across all pages and
1328     * revisions where this comment might appear.
1329     *
1330     * Multiple comments on a page can have the same name; use ID to distinguish them.
1331     */
1332    private function computeName( ContentThreadItem $threadItem ): string {
1333        $name = null;
1334
1335        if ( $threadItem instanceof ContentHeadingItem ) {
1336            $name = 'h-';
1337            $mainComment = $threadItem->getOldestReply();
1338        } elseif ( $threadItem instanceof ContentCommentItem ) {
1339            $name = 'c-';
1340            $mainComment = $threadItem;
1341        } else {
1342            throw new InvalidArgumentException( 'Unknown ThreadItem type' );
1343        }
1344
1345        if ( $mainComment ) {
1346            $name .= $this->truncateForId( str_replace( ' ', '_', $mainComment->getAuthor() ) ) .
1347                '-' . $mainComment->getTimestampString();
1348        }
1349
1350        return $name;
1351    }
1352
1353    private function buildThreads( ContentThreadItemSet $result ): void {
1354        $lastHeading = null;
1355        $replies = [];
1356
1357        foreach ( $result->getThreadItems() as $threadItem ) {
1358            if ( count( $replies ) < $threadItem->getLevel() ) {
1359                // Someone skipped an indentation level (or several). Pretend that the previous reply
1360                // covers multiple indentation levels, so that following comments get connected to it.
1361                $threadItem->addWarning( 'Comment skips indentation level' );
1362                while ( count( $replies ) < $threadItem->getLevel() ) {
1363                    $replies[] = end( $replies );
1364                }
1365            }
1366
1367            if ( $threadItem instanceof ContentHeadingItem ) {
1368                // New root (thread)
1369                // Attach as a sub-thread to preceding higher-level heading.
1370                // Any replies will appear in the tree twice, under the main-thread and the sub-thread.
1371                $maybeParent = $lastHeading;
1372                while ( $maybeParent && $maybeParent->getHeadingLevel() >= $threadItem->getHeadingLevel() ) {
1373                    $maybeParent = $maybeParent->getParent();
1374                }
1375                if ( $maybeParent ) {
1376                    $threadItem->setParent( $maybeParent );
1377                    $maybeParent->addReply( $threadItem );
1378                }
1379                $lastHeading = $threadItem;
1380            } elseif ( isset( $replies[ $threadItem->getLevel() - 1 ] ) ) {
1381                // Add as a reply to the closest less-nested comment
1382                $threadItem->setParent( $replies[ $threadItem->getLevel() - 1 ] );
1383                $threadItem->getParent()->addReply( $threadItem );
1384            } else {
1385                $threadItem->addWarning( 'Comment could not be connected to a thread' );
1386            }
1387
1388            $replies[ $threadItem->getLevel() ] = $threadItem;
1389            // Cut off more deeply nested replies
1390            array_splice( $replies, $threadItem->getLevel() + 1 );
1391        }
1392    }
1393
1394    /**
1395     * Set the IDs and names used to refer to comments and headings.
1396     * This has to be a separate pass because we don't have the list of replies before
1397     * this point.
1398     */
1399    private function computeIdsAndNames( ContentThreadItemSet $result ): void {
1400        foreach ( $result->getThreadItems() as $threadItem ) {
1401            $name = $this->computeName( $threadItem );
1402            $threadItem->setName( $name );
1403
1404            $id = $this->computeId( $threadItem, $result );
1405            $threadItem->setId( $id );
1406            $legacyId = $this->computeId( $threadItem, $result, true );
1407            if ( $legacyId !== $id ) {
1408                $threadItem->setLegacyId( $legacyId );
1409            }
1410
1411            $result->updateIdAndNameMaps( $threadItem );
1412        }
1413    }
1414}