Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.86% covered (warning)
80.86%
207 / 256
42.86% covered (danger)
42.86%
6 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
DataAccess
80.86% covered (warning)
80.86%
207 / 256
42.86% covered (danger)
42.86%
6 / 14
112.40
0.00% covered (danger)
0.00%
0 / 1
 getCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setCache
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getPageInfo
95.12% covered (success)
95.12%
39 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 getFileInfo
65.28% covered (warning)
65.28%
47 / 72
0.00% covered (danger)
0.00%
0 / 1
45.15
 stripProto
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 mergeMetadata
50.00% covered (danger)
50.00%
13 / 26
0.00% covered (danger)
0.00%
0 / 1
26.12
 parseWikitext
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 preprocessWikitext
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 fetchTemplateSource
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
3.00
 fetchTemplateData
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 logLinterData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 toPrefixedText
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addTrackingCategory
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
1<?php
2
3declare( strict_types = 1 );
4
5namespace Wikimedia\Parsoid\Config\Api;
6
7use Wikimedia\Parsoid\Config\Api\SiteConfig as ApiSiteConfig;
8use Wikimedia\Parsoid\Config\DataAccess as IDataAccess;
9use Wikimedia\Parsoid\Config\PageConfig;
10use Wikimedia\Parsoid\Config\PageContent;
11use Wikimedia\Parsoid\Config\SiteConfig as ISiteConfig;
12use Wikimedia\Parsoid\Core\ContentMetadataCollector;
13use Wikimedia\Parsoid\Core\ContentMetadataCollectorStringSets as CMCSS;
14use Wikimedia\Parsoid\Core\LinkTarget;
15use Wikimedia\Parsoid\Fragments\PFragment;
16use Wikimedia\Parsoid\Fragments\StripState;
17use Wikimedia\Parsoid\Mocks\MockPageContent;
18use Wikimedia\Parsoid\Utils\PHPUtils;
19use Wikimedia\Parsoid\Utils\Title;
20use Wikimedia\Parsoid\Utils\TitleValue;
21
22/**
23 * DataAccess via MediaWiki's Action API
24 *
25 * Note this is intended for testing, not performance.
26 */
27class DataAccess extends IDataAccess {
28
29    /** @var ApiHelper */
30    private $api;
31
32    /**
33     * @var bool Should we strip the protocol from returned URLs?
34     * Generally this should be true, since the protocol of the API
35     * request doesn't necessarily match the protocol of article
36     * access; ie, we could be using https to access the API but emit
37     * article content which can be read with http.  But for running
38     * parserTests, we need to include the protocol in order to match
39     * the parserTest configuration in core.
40     */
41    private $stripProto;
42
43    /**
44     * @name Caching
45     * @todo Someone should librarize MediaWiki core's MapCacheLRU so we can
46     *  pull it in via composer and use it here.
47     * @{
48     */
49
50    private const MAX_CACHE_LEN = 100;
51
52    /**
53     * @var array
54     */
55    private $cache = [];
56
57    private ISiteConfig $siteConfig;
58
59    /**
60     * Get from cache
61     * @param string $key
62     * @return mixed
63     */
64    private function getCache( string $key ) {
65        if ( isset( $this->cache[$key] ) ) {
66            $ret = $this->cache[$key];
67            // The LRU cache uses position in the array to indicate recency, so
68            // move the accessed key to the end.
69            unset( $this->cache[$key] );
70            $this->cache[$key] = $ret;
71            return $ret;
72        }
73        return null;
74    }
75
76    /**
77     * Set a value into cache
78     * @param string $key
79     * @param mixed $value Not null.
80     */
81    private function setCache( string $key, $value ): void {
82        if ( isset( $this->cache[$key] ) ) {
83            // The LRU cache uses position in the array to indicate recency, so
84            // remove the old entry so the new version goes at the end.
85            unset( $this->cache[$key] );
86        } elseif ( count( $this->cache ) >= self::MAX_CACHE_LEN ) {
87            reset( $this->cache );
88            $evictKey = key( $this->cache );
89            unset( $this->cache[$evictKey] );
90        }
91        $this->cache[$key] = $value;
92    }
93
94    /** @} */
95
96    /**
97     * @param ApiHelper $api
98     * @param ISiteConfig $siteConfig
99     * @param array $opts
100     */
101    public function __construct( ApiHelper $api, ISiteConfig $siteConfig, array $opts ) {
102        $this->api = $api;
103        $this->siteConfig = $siteConfig;
104        $this->stripProto = $opts['stripProto'] ?? true;
105    }
106
107    /** @inheritDoc */
108    public function getPageInfo( $pageConfigOrTitle, array $titles ): array {
109        $contextTitle = $pageConfigOrTitle instanceof PageConfig ?
110            $pageConfigOrTitle->getLinkTarget() : $pageConfigOrTitle;
111
112        if ( !$titles ) {
113            return [];
114        }
115
116        $ret = [];
117        $pageConfigTitle = $this->toPrefixedText( $contextTitle );
118        foreach ( array_chunk( $titles, 50 ) as $batch ) {
119            $data = $this->api->makeRequest( [
120                'action' => 'query',
121                'prop' => 'info',
122                'inprop' => 'linkclasses',
123                'inlinkcontext' => $pageConfigTitle,
124                'titles' => implode( '|', $batch ),
125            ] )['query'];
126            $norm = [];
127            if ( isset( $data['normalized'] ) ) {
128                foreach ( $data['normalized'] as $n ) {
129                    $from = $n['from'];
130                    if ( $n['fromencoded'] ) {
131                        $from = rawurldecode( $from );
132                    }
133                    $norm[$from] = $n['to'];
134                }
135            }
136            $pages = [];
137            foreach ( $data['pages'] as $p ) {
138                $pages[$p['title']] = $p;
139            }
140            foreach ( $batch as $title ) {
141                $ttitle = $title;
142                while ( isset( $norm[$ttitle] ) ) {
143                    $ttitle = $norm[$ttitle];
144                }
145                $page = $pages[$ttitle] ?? [];
146                $ret[$title] = [
147                    'pageId' => $page['pageid'] ?? null,
148                    'revId' => $page['lastrevid'] ?? null,
149                    'missing' => $page['missing'] ?? false,
150                    'known' => ( $page['known'] ?? false ),
151                    'redirect' => $page['redirect'] ?? false,
152                    'linkclasses' => $page['linkclasses'] ?? [],
153                    'invalid' => $page['invalid'] ?? false,
154                ];
155                if ( !( $ret[$title]['missing'] || $ret[$title]['invalid'] ) ) {
156                    $ret[$title]['known'] = true;
157                }
158            }
159        }
160
161        return $ret;
162    }
163
164    /** @inheritDoc */
165    public function getFileInfo( PageConfig $pageConfig, array $files ): array {
166        $pageConfigTitle = $this->toPrefixedText( $pageConfig->getLinkTarget() );
167        $sc = $this->siteConfig;
168        if ( $sc instanceof ApiSiteConfig && $sc->hasVideoInfo() ) {
169            $prefix = "vi";
170            $propName = "videoinfo";
171        } else {
172            $prefix = "ii";
173            $propName = "imageinfo";
174        }
175        $apiArgs2 = [
176            'action' => 'query',
177            'format' => 'json',
178            'formatversion' => 2,
179            'rawcontinue' => 1,
180            'prop' => $propName,
181            "{$prefix}badfilecontexttitle" => $pageConfigTitle,
182            "{$prefix}prop" => implode( '|', [
183                'mediatype', 'mime', 'size', 'url', 'badfile', 'sha1', 'timestamp'
184            ] )
185        ];
186        if ( $prefix === 'vi' ) {
187            $apiArgs2["viprop"] .= '|derivatives|timedtext';
188        }
189        $ret = [];
190        foreach ( $files as $file ) {
191            $apiArgs = $apiArgs2;  // Copy since we modify it
192            $name = $file[0];
193            $dims = $file[1];
194
195            $imgNS = $sc->namespaceName( $sc->canonicalNamespaceId( 'file' ) );
196            $apiArgs['titles'] = "$imgNS:$name";
197            $needsWidth = isset( $dims['page'] ) || isset( $dims['lang'] );
198            if ( isset( $dims['width'] ) ) {
199                $apiArgs["{$prefix}urlwidth"] = $dims['width'];
200                if ( $needsWidth ) {
201                    if ( isset( $dims['page'] ) ) {  // PDF
202                        $apiArgs["{$prefix}urlparam"] = "page{$dims['page']}-{$dims['width']}px";
203                    } elseif ( isset( $dims['lang'] ) ) {  // SVG
204                        $apiArgs["{$prefix}urlparam"] = "lang{$dims['lang']}-{$dims['width']}px";
205                    }
206                    $needsWidth = false;
207                }
208            }
209            if ( isset( $dims['height'] ) ) {
210                $apiArgs["{$prefix}urlheight"] = $dims['height'];
211            }
212            if ( isset( $dims['seek'] ) ) {
213                $apiArgs["{$prefix}urlparam"] = "seek={$dims['seek']}";
214            }
215
216            do {
217                $data = $this->api->makeRequest( $apiArgs );
218                // Expect exactly 1 row
219                $fileinfo = $data['query']['pages'][0][$propName][0];
220                // Corner case: if page is set, the core ImageInfo API doesn't
221                // respect it *unless* width is set as well.  So repeat the
222                // request if necessary.
223                if ( isset( $fileinfo['pagecount'] ) && !isset( $dims['page'] ) ) {
224                    $dims['page'] = 1; # also ensures we won't get here again
225                    $needsWidth = true;
226                }
227                if ( $needsWidth && !isset( $fileinfo['filemissing'] ) ) {
228                    $needsWidth = false; # ensure we won't get here again
229                    $width = $fileinfo['width'];
230                    $apiArgs["{$prefix}urlwidth"] = $width;
231                    if ( isset( $dims['page'] ) ) {  // PDF
232                        $apiArgs["{$prefix}urlparam"] = "page{$dims['page']}-{$width}px";
233                    } elseif ( isset( $dims['lang'] ) ) {  // SVG
234                        $apiArgs["{$prefix}urlparam"] = "lang{$dims['lang']}-{$width}px";
235                    }
236                    continue;
237                }
238                break;
239            } while ( true );
240
241            if ( isset( $fileinfo['filemissing'] ) ) {
242                $fileinfo = null;
243            } else {
244                $fileinfo['badFile'] = $data['query']['pages'][0]['badfile'];
245                $this->stripProto( $fileinfo, 'url' );
246                $this->stripProto( $fileinfo, 'thumburl' );
247                $this->stripProto( $fileinfo, 'descriptionurl' );
248                $this->stripProto( $fileinfo, 'descriptionshorturl' );
249                foreach ( $fileinfo['responsiveUrls'] ?? [] as $density => $url ) {
250                    $this->stripProto( $fileinfo['responsiveUrls'], (string)$density );
251                }
252                if ( $prefix === 'vi' ) {
253                    foreach ( $fileinfo['thumbdata']['derivatives'] ?? [] as $j => $d ) {
254                        $this->stripProto( $fileinfo['thumbdata']['derivatives'][$j], 'src' );
255                    }
256                    foreach ( $fileinfo['thumbdata']['timedtext'] ?? [] as $j => $d ) {
257                        $this->stripProto( $fileinfo['thumbdata']['timedtext'][$j], 'src' );
258                    }
259                }
260            }
261            $ret[] = $fileinfo;
262        }
263        return $ret;
264    }
265
266    /**
267     * Convert the given URL into protocol-relative form.
268     *
269     * @param ?array &$obj
270     * @param string $key
271     */
272    private function stripProto( ?array &$obj, string $key ): void {
273        if ( $obj !== null && !empty( $obj[$key] ) && $this->stripProto ) {
274            $obj[$key] = preg_replace( '#^https?://#', '//', $obj[$key] );
275        }
276    }
277
278    /**
279     * Transfer the metadata returned in an API result into our
280     * ContentMetadataCollector.
281     * @param array $data
282     * @param ContentMetadataCollector $metadata
283     */
284    private function mergeMetadata( array $data, ContentMetadataCollector $metadata ): void {
285        foreach ( ( $data['categories'] ?? [] ) as $c ) {
286            $tv = TitleValue::tryNew(
287                14, // NS_CATEGORY,
288                $c['category']
289            );
290            $metadata->addCategory( $tv, $c['sortkey'] );
291        }
292        $metadata->appendOutputStrings( CMCSS::MODULE, $data['modules'] ?? [] );
293        $metadata->appendOutputStrings( CMCSS::MODULE_STYLE, $data['modulestyles'] ?? [] );
294        foreach ( ( $data['jsconfigvars'] ?? [] ) as $key => $value ) {
295            $strategy = 'write-once';
296            if ( is_array( $value ) ) {
297                // Strategy value will be exposed by change
298                // I974d9ecfb4ca8b22361d25c4c70fc5e55c39d5ed in core.
299                $strategy = $value['_mw-strategy'] ?? 'write-once';
300                unset( $value['_mw-strategy'] );
301            }
302            if ( $strategy === 'union' ) {
303                foreach ( $value as $item => $ignore ) {
304                    $metadata->appendJsConfigVar( $key, $item );
305                }
306            } else {
307                $metadata->setJsConfigVar( $key, $value );
308            }
309        }
310        foreach ( ( $data['externallinks'] ?? [] ) as $url ) {
311            $metadata->addExternalLink( $url );
312        }
313        foreach ( ( $data['properties'] ?? [] ) as $name => $value ) {
314            if ( is_string( $value ) ) {
315                $metadata->setUnsortedPageProperty( $name, $value );
316            } elseif ( is_numeric( $value ) ) {
317                $metadata->setNumericPageProperty( $name, $value );
318            } elseif ( is_bool( $value ) ) {
319                // Deprecated back-compat
320                $metadata->setNumericPageProperty( $name, (int)$value );
321            } else {
322                // Non-scalar values deprecatedin 1.42; drop them.
323            }
324        }
325    }
326
327    /** @inheritDoc */
328    public function parseWikitext(
329        PageConfig $pageConfig,
330        ContentMetadataCollector $metadata,
331        string $wikitext
332    ): string {
333        $revid = $pageConfig->getRevisionId();
334        $pageConfigTitle = $this->toPrefixedText( $pageConfig->getLinkTarget() );
335        $key = implode( ':', [ 'parse', md5( $pageConfigTitle ), md5( $wikitext ), $revid ] );
336        $data = $this->getCache( $key );
337        if ( $data === null ) {
338            $params = [
339                'action' => 'parse',
340                'title' => $pageConfigTitle,
341                'text' => $wikitext,
342                'contentmodel' => 'wikitext',
343                'prop' => 'text|modules|jsconfigvars|categories|properties|externallinks',
344                'disablelimitreport' => 1,
345                'wrapoutputclass' => '',
346                'showstrategykeys' => 1,
347            ];
348            if ( $revid !== null ) {
349                $params['revid'] = $revid;
350            }
351            $data = $this->api->makeRequest( $params )['parse'];
352            $this->setCache( $key, $data );
353        }
354        $this->mergeMetadata( $data, $metadata );
355        return $data['text']; # HTML
356    }
357
358    /** @inheritDoc */
359    public function preprocessWikitext(
360        PageConfig $pageConfig,
361        ContentMetadataCollector $metadata,
362        $wikitext
363    ) {
364        $ss = StripState::new();
365        if ( !is_string( $wikitext ) ) {
366            // This is a bit of a hack -- we pass our parsoid strip markers
367            // through core, then find them in the output and map them back
368            // to fragments.  This is the only external exposure of our
369            // Parsoid-internal strip markers, and they must not conflict
370            // with core's: see StripState::MARKER_PREFIX for more details.
371            $wikitext = $wikitext->asMarkedWikitext( $ss );
372        }
373        $revid = $pageConfig->getRevisionId();
374        $pageConfigTitle = $this->toPrefixedText( $pageConfig->getLinkTarget() );
375        $key = implode( ':', [ 'preprocess', md5( $pageConfigTitle ), md5( $wikitext ), $revid ] );
376        $data = $this->getCache( $key );
377        if ( $data === null ) {
378            $params = [
379                'action' => 'expandtemplates',
380                'title' => $pageConfigTitle,
381                'text' => $wikitext,
382                'prop' => 'wikitext|modules|jsconfigvars|categories|properties',
383                'showstrategykeys' => 1,
384            ];
385            if ( $revid !== null ) {
386                $params['revid'] = $revid;
387            }
388            $data = $this->api->makeRequest( $params )['expandtemplates'];
389            $this->setCache( $key, $data );
390        }
391
392        $this->mergeMetadata( $data, $metadata );
393
394        // Find our strip markers in the API result and map them back to
395        // fragments using our stored strip state
396        return PFragment::fromSplitWt( $ss->splitWt( $data['wikitext'] ) );
397    }
398
399    /** @inheritDoc */
400    public function fetchTemplateSource(
401        PageConfig $pageConfig, LinkTarget $title
402    ): ?PageContent {
403        $title = $this->toPrefixedText( $title );
404        $key = implode( ':', [ 'content', md5( $title ) ] );
405        $ret = $this->getCache( $key );
406        if ( $ret === null ) {
407            $params = [
408                'action' => 'query',
409                'prop' => 'revisions',
410                'rvprop' => 'content',
411                'rvslots' => '*',
412                'titles' => $title,
413                'rvlimit' => 1,
414            ];
415
416            $data = $this->api->makeRequest( $params );
417            $pageData = $data['query']['pages'][0];
418            if ( isset( $pageData['missing'] ) ) {
419                return null;
420            } else {
421                $ret = $pageData['revisions'][0]['slots'];
422                // PORT-FIXME set the redirect field if needed
423                $this->setCache( $key, $ret );
424            }
425        }
426        return new MockPageContent( $ret );
427    }
428
429    /** @inheritDoc */
430    public function fetchTemplateData( PageConfig $pageConfig, LinkTarget $title ): ?array {
431        $title = $this->toPrefixedText( $title );
432        $key = implode( ':', [ 'templatedata', md5( $title ) ] );
433        $ret = $this->getCache( $key );
434        if ( $ret === null ) {
435            $data = $this->api->makeRequest( [
436                'action' => 'templatedata',
437                'includeMissingTitles' => 1,
438                'titles' => $title,
439                'redirects' => 1,
440            ] )['pages'];
441            $ret = reset( $data );
442            $this->setCache( $key, $ret );
443        }
444        return $ret;
445    }
446
447    /** @inheritDoc */
448    public function logLinterData( PageConfig $pageConfig, array $lints ): void {
449        foreach ( $lints as $l ) {
450            error_log( PHPUtils::jsonEncode( $l ) );
451        }
452    }
453
454    /**
455     * Helper to turn a LinkTarget object into the "prefixed text" title form
456     * expected by the MediaWiki action API.
457     * @param LinkTarget $linkTarget
458     * @return string The title, as prefixed text
459     */
460    private function toPrefixedText( LinkTarget $linkTarget ): string {
461        return Title::newFromLinkTarget(
462            $linkTarget, $this->siteConfig
463        )->getPrefixedText();
464    }
465
466    /** @inheritDoc */
467    public function addTrackingCategory(
468        PageConfig $pageConfig,
469        ContentMetadataCollector $metadata,
470        string $key
471    ): void {
472        $pageConfigTitle = $this->toPrefixedText( $pageConfig->getLinkTarget() );
473        $cacheKey = implode( ':', [ 'allmessages', md5( $pageConfigTitle ), md5( $key ) ] );
474        $data = $this->getCache( $cacheKey );
475        if ( $data === null ) {
476            $params = [
477                'action' => 'query',
478                'meta' => 'allmessages',
479                'amtitle' => $pageConfigTitle,
480                'ammessages' => $key,
481                'amenableparser' => 1,
482            ];
483            $data = $this->api->makeRequest( $params )['query']['allmessages'][0];
484            $this->setCache( $cacheKey, $data );
485        }
486        if ( isset( $data['missing'] ) ) {
487            return;
488        }
489        $tv = TitleValue::tryNew(
490            14, // NS_CATEGORY,
491            $data['content']
492        );
493        $metadata->addCategory( $tv );
494    }
495}