Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
6.64% covered (danger)
6.64%
16 / 241
16.67% covered (danger)
16.67%
2 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DataAccess
6.64% covered (danger)
6.64%
16 / 241
16.67% covered (danger)
16.67%
2 / 12
3089.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 makeTransformOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 getPageInfo
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
90
 getFileInfo
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
182
 prepareParser
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 makeLimitReport
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parseWikitext
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessWikitext
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
272
 fetchTemplateSource
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 fetchTemplateData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 addTrackingCategory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 logLinterData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Copyright (C) 2011-2022 Wikimedia Foundation and others.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18 */
19
20namespace MediaWiki\Parser\Parsoid\Config;
21
22use MediaTransformError;
23use MediaWiki\Cache\LinkBatchFactory;
24use MediaWiki\Category\TrackingCategories;
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\Content\Transform\ContentTransformer;
27use MediaWiki\FileRepo\File\File;
28use MediaWiki\FileRepo\RepoGroup;
29use MediaWiki\HookContainer\HookContainer;
30use MediaWiki\HookContainer\HookRunner;
31use MediaWiki\Html\Html;
32use MediaWiki\Language\LanguageCode;
33use MediaWiki\Linker\Linker;
34use MediaWiki\MainConfigNames;
35use MediaWiki\Page\File\BadFileLookup;
36use MediaWiki\Parser\Parser;
37use MediaWiki\Parser\ParserFactory;
38use MediaWiki\Parser\ParserOptions;
39use MediaWiki\Parser\ParserOutput;
40use MediaWiki\Parser\PPFrame;
41use MediaWiki\Title\Title;
42use Wikimedia\Assert\UnreachableException;
43use Wikimedia\Parsoid\Config\DataAccess as IDataAccess;
44use Wikimedia\Parsoid\Config\PageConfig as IPageConfig;
45use Wikimedia\Parsoid\Config\PageContent as IPageContent;
46use Wikimedia\Parsoid\Core\ContentMetadataCollector;
47use Wikimedia\Parsoid\Core\LinkTarget as ParsoidLinkTarget;
48use Wikimedia\Parsoid\Fragments\HtmlPFragment;
49use Wikimedia\Parsoid\Fragments\PFragment;
50use Wikimedia\Parsoid\Fragments\WikitextPFragment;
51use Wikimedia\Rdbms\ReadOnlyMode;
52
53/**
54 * Implement Parsoid's abstract class for data access.
55 *
56 * @since 1.39
57 * @internal
58 */
59class DataAccess extends IDataAccess {
60    public const CONSTRUCTOR_OPTIONS = [
61        MainConfigNames::SVGMaxSize,
62    ];
63
64    private RepoGroup $repoGroup;
65    private BadFileLookup $badFileLookup;
66    private HookContainer $hookContainer;
67    private HookRunner $hookRunner;
68    private ContentTransformer $contentTransformer;
69    private TrackingCategories $trackingCategories;
70    private ParserFactory $parserFactory;
71    /** Lazy-created via self::prepareParser() */
72    private ?Parser $parser = null;
73    private PPFrame $ppFrame;
74    private ?PageConfig $previousPageConfig = null;
75    private ServiceOptions $config;
76    private ReadOnlyMode $readOnlyMode;
77    private LinkBatchFactory $linkBatchFactory;
78    private int $markerIndex = 0;
79
80    /**
81     * @param ServiceOptions $config MediaWiki main configuration object
82     * @param RepoGroup $repoGroup
83     * @param BadFileLookup $badFileLookup
84     * @param HookContainer $hookContainer
85     * @param ContentTransformer $contentTransformer
86     * @param TrackingCategories $trackingCategories
87     * @param ReadOnlyMode $readOnlyMode used to disable linting when the
88     *   database is read-only.
89     * @param ParserFactory $parserFactory A legacy parser factory,
90     *   for PST/preprocessing/extension handling
91     * @param LinkBatchFactory $linkBatchFactory
92     */
93    public function __construct(
94        ServiceOptions $config,
95        RepoGroup $repoGroup,
96        BadFileLookup $badFileLookup,
97        HookContainer $hookContainer,
98        ContentTransformer $contentTransformer,
99        TrackingCategories $trackingCategories,
100        ReadOnlyMode $readOnlyMode,
101        ParserFactory $parserFactory,
102        LinkBatchFactory $linkBatchFactory
103    ) {
104        $config->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
105        $this->config = $config;
106        $this->repoGroup = $repoGroup;
107        $this->badFileLookup = $badFileLookup;
108        $this->hookContainer = $hookContainer;
109        $this->contentTransformer = $contentTransformer;
110        $this->trackingCategories = $trackingCategories;
111        $this->readOnlyMode = $readOnlyMode;
112        $this->linkBatchFactory = $linkBatchFactory;
113
114        $this->hookRunner = new HookRunner( $hookContainer );
115
116        $this->parserFactory = $parserFactory;
117        $this->previousPageConfig = null; // ensure we initialize parser options
118    }
119
120    /**
121     * @param IPageConfig $pageConfig
122     * @param File $file
123     * @param array $hp
124     * @return array
125     */
126    private function makeTransformOptions( IPageConfig $pageConfig, $file, array $hp ): array {
127        // Validate the input parameters like Parser::makeImage()
128        $handler = $file->getHandler();
129        if ( !$handler ) {
130            return []; // will get iconThumb()
131        }
132        foreach ( $hp as $name => $value ) {
133            if ( !$handler->validateParam( $name, $value ) ) {
134                unset( $hp[$name] );
135            }
136        }
137
138        // This part is similar to Linker::makeImageLink(). If there is no width,
139        // set one based on the source file size.
140        $page = $hp['page'] ?? 0;
141        if ( !isset( $hp['width'] ) ) {
142            if ( isset( $hp['height'] ) && $file->isVectorized() ) {
143                // If it's a vector image, and user only specifies height
144                // we don't want it to be limited by its "normal" width.
145                $hp['width'] = $this->config->get( MainConfigNames::SVGMaxSize );
146            } else {
147                $hp['width'] = $file->getWidth( $page );
148            }
149
150            // We don't need to fill in a default thumbnail width here, since
151            // that is done by Parsoid. Parsoid always sets the width parameter
152            // for thumbnails.
153        }
154
155        // Parser::makeImage() always sets this
156        $hp['targetlang'] = LanguageCode::bcp47ToInternal(
157            $pageConfig->getPageLanguageBcp47()
158        );
159
160        return $hp;
161    }
162
163    /** @inheritDoc */
164    public function getPageInfo( $pageConfigOrTitle, array $titles ): array {
165        if ( $pageConfigOrTitle instanceof IPageConfig ) {
166            $context_title = Title::newFromLinkTarget(
167                $pageConfigOrTitle->getLinkTarget()
168            );
169        } elseif ( is_string( $pageConfigOrTitle ) ) {
170            // Temporary, deprecated.
171            $context_title = Title::newFromTextThrow( $pageConfigOrTitle );
172        } elseif ( $pageConfigOrTitle instanceof ParsoidLinkTarget ) {
173            $context_title = Title::newFromLinkTarget( $pageConfigOrTitle );
174        } else {
175            throw new UnreachableException( "Bad type for argument 1" );
176        }
177        $titleObjs = [];
178        $pagemap = [];
179        $classes = [];
180        $ret = [];
181        foreach ( $titles as $name ) {
182            $t = Title::newFromText( $name );
183            // Filter out invalid titles. Title::newFromText in core (not our bespoke
184            // version in src/Utils/Title.php) can return null for invalid titles.
185            if ( !$t ) {
186                // FIXME: This is a bandaid to patch up the fact that Env::makeTitle treats
187                // this as a valid title, but Title::newFromText treats it as invalid.
188                // T237535
189                // This matches what ApiQuery::outputGeneralPageInfo() would
190                // return for an invalid title.
191                $ret[$name] = [
192                    'pageId' => -1,
193                    'revId' => -1,
194                    'invalid' => true,
195                    'invalidreason' => 'The requested page title is invalid',
196                ];
197            } else {
198                $titleObjs[$name] = $t;
199            }
200        }
201        $this->linkBatchFactory->newLinkBatch( $titleObjs )
202            ->setCaller( __METHOD__ )
203            ->execute();
204
205        foreach ( $titleObjs as $obj ) {
206            $pdbk = $obj->getPrefixedDBkey();
207            $pagemap[$obj->getArticleID()] = $pdbk;
208            $classes[$pdbk] = $obj->isRedirect() ? 'mw-redirect' : '';
209        }
210        $this->hookRunner->onGetLinkColours(
211            # $classes is passed by reference and mutated
212            $pagemap, $classes, $context_title
213        );
214
215        foreach ( $titleObjs as $name => $obj ) {
216            /** @var Title $obj */
217            $pdbk = $obj->getPrefixedDBkey();
218            $c = preg_split(
219                '/\s+/', $classes[$pdbk] ?? '', -1, PREG_SPLIT_NO_EMPTY
220            );
221            $ret[$name] = [
222                'pageId' => $obj->getArticleID(),
223                'revId' => $obj->getLatestRevID(),
224                'missing' => !$obj->exists(),
225                'known' => $obj->isKnown(),
226                'redirect' => $obj->isRedirect(),
227                'linkclasses' => $c, # See ApiQueryInfo::getLinkClasses() in core
228            ];
229        }
230        return $ret;
231    }
232
233    /** @inheritDoc */
234    public function getFileInfo( IPageConfig $pageConfig, array $files ): array {
235        $page = Title::newFromLinkTarget( $pageConfig->getLinkTarget() );
236
237        $keys = [];
238        foreach ( $files as $f ) {
239            $keys[] = $f[0];
240        }
241        $fileObjs = $this->repoGroup->findFiles( $keys );
242
243        $ret = [];
244        foreach ( $files as $f ) {
245            $filename = $f[0];
246            $dims = $f[1];
247
248            /** @var File $file */
249            $file = $fileObjs[$filename] ?? null;
250            if ( !$file ) {
251                $ret[] = null;
252                continue;
253            }
254
255            // See Linker::makeImageLink; 'page' is a key in $handlerParams
256            // core uses 'false' as the default then casts to (int) => 0
257            $pageNum = $dims['page'] ?? 0;
258
259            $result = [
260                'width' => $file->getWidth( $pageNum ),
261                'height' => $file->getHeight( $pageNum ),
262                'size' => $file->getSize(),
263                'mediatype' => $file->getMediaType(),
264                'mime' => $file->getMimeType(),
265                'url' => $file->getFullUrl(),
266                'mustRender' => $file->mustRender(),
267                'badFile' => $this->badFileLookup->isBadFile( $filename, $page ),
268                'timestamp' => $file->getTimestamp(),
269                'sha1' => $file->getSha1(),
270            ];
271
272            $length = $file->getLength();
273            if ( $length ) {
274                $result['duration'] = (float)$length;
275            }
276
277            if ( isset( $dims['seek'] ) ) {
278                $dims['thumbtime'] = $dims['seek'];
279            }
280
281            $txopts = $this->makeTransformOptions( $pageConfig, $file, $dims );
282            $mto = $file->transform( $txopts );
283            if ( $mto ) {
284                if ( $mto->isError() && $mto instanceof MediaTransformError ) {
285                    $result['thumberror'] = $mto->toText();
286                } else {
287                    if ( $txopts ) {
288                        // Do srcset scaling
289                        Linker::processResponsiveImages( $file, $mto, $txopts );
290                        if ( count( $mto->responsiveUrls ) ) {
291                            $result['responsiveUrls'] = [];
292                            foreach ( $mto->responsiveUrls as $density => $url ) {
293                                $result['responsiveUrls'][$density] = $url;
294                            }
295                        }
296                    }
297
298                    // Proposed MediaTransformOutput serialization method for T51896 etc.
299                    // Note that getAPIData(['fullurl']) would return
300                    // UrlUtils::expand(), which wouldn't respect the wiki's
301                    // protocol preferences -- instead it would use the
302                    // protocol used for the API request.
303                    if ( is_callable( [ $mto, 'getAPIData' ] ) ) {
304                        $result['thumbdata'] = $mto->getAPIData( [ 'withhash' ] );
305                    }
306
307                    $result['thumburl'] = $mto->getUrl();
308                    $result['thumbwidth'] = $mto->getWidth();
309                    $result['thumbheight'] = $mto->getHeight();
310                }
311            } else {
312                $result['thumberror'] = "Presumably, invalid parameters, despite validation.";
313            }
314
315            $ret[] = $result;
316        }
317
318        return $ret;
319    }
320
321    /**
322     * Prepare MediaWiki's parser for preprocessing or extension tag parsing,
323     * clearing its state if necessary.
324     *
325     * @note The caller is expected to call Parser::resetOutput() and
326     * reset the watcher if needed on $pageConfig->getParserOptions()
327     * as needed.
328     *
329     * @param IPageConfig $pageConfig
330     * @param int $outputType
331     * @return Parser
332     */
333    private function prepareParser( IPageConfig $pageConfig, int $outputType ) {
334        '@phan-var PageConfig $pageConfig'; // @var PageConfig $pageConfig
335        // Clear the state only when the PageConfig changes, so that Parser's internal caches can
336        // be retained. This should also provide better compatibility with extension tags.
337        $clearState = $this->previousPageConfig !== $pageConfig;
338        $this->previousPageConfig = $pageConfig;
339        $parserOptions = $pageConfig->getParserOptions();
340        $oldWatcher = $parserOptions->registerWatcher( null );
341        // Use the same legacy parser object for all calls to extension tag
342        // processing, for greater compatibility.
343        $this->parser ??= $this->parserFactory->create();
344        $this->parser->setStripExtTags( false );
345        $this->parser->startExternalParse(
346            Title::newFromLinkTarget( $pageConfig->getLinkTarget() ),
347            $parserOptions,
348            $outputType, $clearState, $pageConfig->getRevisionId() );
349
350        // Retain a PPFrame object between preprocess requests since it contains
351        // some useful caches.
352        if ( $clearState ) {
353            $this->ppFrame = $this->parser->getPreprocessor()->newFrame();
354            // If $clearState is true, then we've reset the parser output and
355            // clobbered the watcher on the parser options; restore the old
356            // one.
357            $parserOptions->registerWatcher( $oldWatcher );
358        }
359        return $this->parser;
360    }
361
362    /** @internal */
363    public function makeLimitReport(
364        IPageConfig $pageConfig,
365        ParserOptions $parserOptions,
366        ParserOutput $parserOutput
367    ) {
368        $parser = $this->prepareParser( $pageConfig, Parser::OT_HTML );
369        // This next call doesn't touch $parser::$mParserOutput so we
370        // don't need to call Parser::resetOutput() here.
371        $parser->makeLimitReport( $parserOptions, $parserOutput );
372    }
373
374    /** @inheritDoc */
375    public function parseWikitext(
376        IPageConfig $pageConfig,
377        ContentMetadataCollector $metadata,
378        string $wikitext
379    ): string {
380        '@phan-var PageConfig $pageConfig'; // @var PageConfig $pageConfig
381        $parser = $this->prepareParser( $pageConfig, Parser::OT_HTML );
382
383        // XXX: Ideally we will eventually have the legacy parser use our
384        // ContentMetadataCollector instead of having a new ParserOutput
385        // created (in Parser::resetOutput() here) which we then have to
386        // manually merge.  On the other hand, this will let us precisely
387        // identify metadata added by $wikitext.
388        $parserOptions = $pageConfig->getParserOptions();
389        $oldWatcher = $parserOptions->registerWatcher( null );
390        $parser->resetOutput();
391
392        $html = $parser->parseExtensionTagAsTopLevelDoc( $wikitext );
393
394        $out = $parser->getOutput();
395        $out->collectMetadata( $metadata ); # merges $out into $metadata
396        $parserOptions->registerWatcher( $oldWatcher );
397
398        return Parser::extractBody( $html );
399    }
400
401    /** @inheritDoc */
402    public function preprocessWikitext(
403        IPageConfig $pageConfig,
404        ContentMetadataCollector $metadata,
405        $wikitext
406    ) {
407        '@phan-var PageConfig $pageConfig'; // @var PageConfig $pageConfig
408
409        $parser = $this->prepareParser( $pageConfig, Parser::OT_PREPROCESS );
410
411        // XXX: Ideally we will eventually have the legacy parser use our
412        // ContentMetadataCollector instead of having a new ParserOutput
413        // created (in Parser::resetOutput() here) which we then have to
414        // manually merge.  On the other hand, this will let us precisely
415        // identify metadata added by $wikitext.
416        $parserOptions = $pageConfig->getParserOptions();
417        $oldWatcher = $parserOptions->registerWatcher( null );
418        $parser->resetOutput();
419
420        if ( $wikitext instanceof PFragment ) {
421            $result = [];
422            $index = 1;
423            $split = $wikitext instanceof WikitextPFragment ?
424                $wikitext->split() : [ $wikitext ];
425            foreach ( $split as $fragment ) {
426                if ( is_string( $fragment ) ) {
427                    $result[] = $fragment;
428                } else {
429                    $marker = Parser::MARKER_PREFIX . '-parsoid-' .
430                        sprintf( '%08X', $this->markerIndex++ ) .
431                        Parser::MARKER_SUFFIX;
432                    $parser->getStripState()->addParsoidOpaque(
433                        $marker, $fragment
434                    );
435                    $result[] = $marker;
436                }
437            }
438            $wikitext = implode( $result );
439        }
440        $this->hookRunner->onParserBeforePreprocess(
441            # $wikitext is passed by reference and mutated
442            $parser, $wikitext, $parser->getStripState()
443        );
444        // New PFragment-based support (T374616)
445        $wikitext = $parser->replaceVariables(
446            $wikitext, $this->ppFrame, false, [
447                'parsoidTopLevelCall' => true,
448                // This is implied by stripExtTags=false and
449                // probably doesn't need to be explicitly passed
450                // any more.
451                'processNowiki' => true,
452            ]
453        );
454        // Where the result has strip state markers, tunnel this content
455        // through Parsoid as a PFragment type.
456        $pieces = $parser->getStripState()->split( $wikitext );
457        if ( count( $pieces ) > 1 || ( $pieces[0]['type'] ?? null ) !== 'string' ) {
458            for ( $i = 0; $i < count( $pieces ); $i++ ) {
459                [ 'type' => $type, 'content' => $content ] = $pieces[$i];
460                if ( $type === 'string' ) {
461                    // wikitext (could include extension tag snippets like <tag..>...</tag>)
462                    $pieces[$i] = $content;
463                } elseif ( $type === 'parsoid' ) {
464                    $pieces[$i] = $pieces[$i]['extra']; // replace w/ fragment
465                } elseif ( $type === 'nowiki' ) {
466                    $extra = $pieces[$i]['extra'] ?? null;
467                    // T388819: If this is from an actual <nowiki>, we
468                    // wrap <span typeof="mw:Nowiki"> around $contents.
469                    if ( $extra === 'nowiki' ) {
470                        $content = Html::rawElement( 'span', [
471                            'typeof' => 'mw:Nowiki',
472                        ], $content );
473                    }
474                    $pieces[$i] = $content ? HtmlPFragment::newFromHtmlString( $content, null ) : '';
475                } else {
476                    // T381709: technically this fragment should
477                    // be subject to language conversion and some
478                    // additional processing
479                    $pieces[$i] = $content ? HtmlPFragment::newFromHtmlString( $content, null ) : '';
480                }
481            }
482            // Concatenate wikitext strings generated by extension tags,
483            // so that PFragment doesn't try to add <nowiki>s between
484            // the pieces to prevent token-gluing.
485            $result = [];
486            $wt = '';
487            foreach ( $pieces as $p ) {
488                if ( is_string( $p ) ) {
489                    $wt .= $p;
490                } else {
491                    $result[] = $wt;
492                    $result[] = $p;
493                    $wt = '';
494                }
495            }
496            $result[] = $wt;
497            // result will be a PFragment, no longer a string.
498            $wikitext = PFragment::fromSplitWt( $result );
499        }
500
501        $out = $parser->getOutput();
502        $out->collectMetadata( $metadata ); # merges $out into $metadata
503        $parserOptions->registerWatcher( $oldWatcher );
504
505        return $wikitext;
506    }
507
508    /** @inheritDoc */
509    public function fetchTemplateSource(
510        IPageConfig $pageConfig, $title
511    ): ?IPageContent {
512        '@phan-var PageConfig $pageConfig'; // @var PageConfig $pageConfig
513        if ( is_string( $title ) ) {
514            $titleObj = Title::newFromTextThrow( $title );
515        } else {
516            $titleObj = Title::newFromLinkTarget( $title );
517        }
518
519        // Use the PageConfig to take advantage of custom template
520        // fetch hooks like FlaggedRevisions, etc.
521        $revRecord = $pageConfig->fetchRevisionRecordOfTemplate( $titleObj );
522
523        return $revRecord ? new PageContent( $revRecord ) : null;
524    }
525
526    /** @inheritDoc */
527    public function fetchTemplateData( IPageConfig $pageConfig, $title ): ?array {
528        $ret = [];
529        if ( !is_string( $title ) ) {
530            $titleObj = Title::newFromLinkTarget( $title );
531            $title = $titleObj->getPrefixedText();
532        }
533        // @todo: This hook needs some clean up: T304899
534        $this->hookRunner->onParserFetchTemplateData(
535            [ $title ],
536            $ret # value returned by reference
537        );
538
539        // Cast value to array since the hook returns this as a stdclass
540        $tplData = $ret[$title] ?? null;
541        if ( $tplData ) {
542            // Deep convert to associative array
543            $tplData = json_decode( json_encode( $tplData ), true );
544        }
545        return $tplData;
546    }
547
548    /**
549     * Add a tracking category with the given key to the metadata for the page.
550     * @param IPageConfig $pageConfig the page on which the tracking category
551     *   is to be added
552     * @param ContentMetadataCollector $metadata The metadata for the page
553     * @param string $key Message key (not localized)
554     */
555    public function addTrackingCategory(
556        IPageConfig $pageConfig,
557        ContentMetadataCollector $metadata,
558        string $key
559    ): void {
560        $page = Title::newFromLinkTarget( $pageConfig->getLinkTarget() );
561        $this->trackingCategories->addTrackingCategory(
562            $metadata, $key, $page
563        );
564    }
565
566    /** @inheritDoc */
567    public function logLinterData( IPageConfig $pageConfig, array $lints ): void {
568        if ( $this->readOnlyMode->isReadOnly() ) {
569            return;
570        }
571
572        $revId = $pageConfig->getRevisionId();
573        $title = Title::newFromLinkTarget(
574            $pageConfig->getLinkTarget()
575        )->getPrefixedText();
576        $pageInfo = $this->getPageInfo( $pageConfig, [ $title ] );
577        $latest = $pageInfo[$title]['revId'];
578
579        // Only send the request if it the latest revision
580        if ( $revId !== null && $revId === $latest ) {
581            $this->hookRunner->onParserLogLinterData(
582                $title, $revId, $lints
583            );
584        }
585    }
586
587}