Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
StubMetadataCollector
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 37
5112
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addCategory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addWarningMsg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addExternalLink
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOutputFlag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendOutputStrings
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setUnsortedPageProperty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setNumericPageProperty
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setExtensionData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setJsConfigVar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendExtensionData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 appendJsConfigVar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLimitReportData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTOCData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 addImage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 addLanguageLink
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 addTemplate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getLinkList
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
156
 collect
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
110
 get
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getCategoryNames
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCategorySortKey
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getPageProperty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtensionData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOutputFlags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getTOCData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIndicator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIndicators
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 linkToString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 stringToLink
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types = 1 );
4
5namespace Wikimedia\Parsoid\Config;
6
7use Psr\Log\LoggerInterface;
8use Psr\Log\LogLevel;
9use Wikimedia\Assert\UnreachableException;
10use Wikimedia\Parsoid\Core\ContentMetadataCollector;
11use Wikimedia\Parsoid\Core\ContentMetadataCollectorCompat;
12use Wikimedia\Parsoid\Core\ContentMetadataCollectorStringSets as CMCSS;
13use Wikimedia\Parsoid\Core\LinkTarget;
14use Wikimedia\Parsoid\Core\TOCData;
15use Wikimedia\Parsoid\Utils\TitleValue;
16
17/**
18 * Minimal implementation of a ContentMetadataCollector which just
19 * records all metadata in an array.  Used for testing or operation
20 * in API mode.
21 */
22class StubMetadataCollector implements ContentMetadataCollector {
23    use ContentMetadataCollectorCompat;
24
25    public const LINKTYPE_CATEGORY = 'category';
26    public const LINKTYPE_LANGUAGE = 'language';
27    public const LINKTYPE_INTERWIKI = 'interwiki';
28    public const LINKTYPE_LOCAL = 'local';
29    public const LINKTYPE_MEDIA = 'media';
30    public const LINKTYPE_SPECIAL = 'special';
31    public const LINKTYPE_TEMPLATE = 'template';
32
33    /** @var SiteConfig */
34    private $siteConfig;
35
36    /** @var LoggerInterface */
37    private $logger;
38
39    /** @var array<string,array> */
40    private $mWarningMsgs = [];
41
42    /** @var array */
43    private $storage = [];
44
45    /** @var string */
46    private const MERGE_STRATEGY_KEY = '_parsoid-strategy_';
47
48    /**
49     * Non-standard merge strategy to use for properties which are *not*
50     * accumulators: "write-once" means that the property should be set
51     * once (although subsequently resetting it to the same value is ok)
52     * and an error will be thrown if there is an attempt to combine
53     * multiple values.
54     *
55     * This strategy is internal to the StubMetadataCollector for now;
56     * ParserOutput implements similar semantics for many of its properties,
57     * but not (yet) in a principled or uniform way.
58     */
59    private const MERGE_STRATEGY_WRITE_ONCE = 'write-once';
60
61    /**
62     * @param SiteConfig $siteConfig Used to resolve title namespaces
63     *  and to log warnings for unsafe metadata updates
64     */
65    public function __construct(
66        SiteConfig $siteConfig
67    ) {
68        $this->siteConfig = $siteConfig;
69        $this->logger = $siteConfig->getLogger();
70    }
71
72    /** @inheritDoc */
73    public function addCategory( $c, $sort = '' ): void {
74        // Numeric strings often become an `int` when passed to addCategory()
75        $this->collect(
76            self::LINKTYPE_CATEGORY,
77            $this->linkToString( $c ),
78            $sort,
79            self::MERGE_STRATEGY_WRITE_ONCE
80        );
81    }
82
83    /** @inheritDoc */
84    public function addWarningMsg( string $msg, ...$args ): void {
85        $this->mWarningMsgs[$msg] = $args;
86    }
87
88    /** @inheritDoc */
89    public function addExternalLink( string $url ): void {
90        $this->collect(
91            'externallinks',
92            $url,
93            '',
94            self::MERGE_STRATEGY_WRITE_ONCE
95        );
96    }
97
98    public function getExternalLinks(): array {
99        return array_keys( $this->get( 'externallinks' ) );
100    }
101
102    /** @inheritDoc */
103    public function setOutputFlag( string $name, bool $value = true ): void {
104        $this->collect( 'outputflags', $name, (string)$value, self::MERGE_STRATEGY_WRITE_ONCE );
105    }
106
107    /** @inheritDoc */
108    public function appendOutputStrings( string $name, array $value ): void {
109        foreach ( $value as $v ) {
110            $this->collect( 'outputstrings', $name, $v );
111        }
112    }
113
114    /** @inheritDoc */
115    public function setUnsortedPageProperty( string $propName, string $value = '' ): void {
116        $this->collect( 'properties', $propName, $value, self::MERGE_STRATEGY_WRITE_ONCE );
117    }
118
119    /** @inheritDoc */
120    public function setNumericPageProperty( string $propName, $numericValue ): void {
121        if ( !is_numeric( $numericValue ) ) {
122            throw new \TypeError( __METHOD__ . " with non-numeric value" );
123        }
124        $value = 0 + $numericValue; # cast to number
125        $this->collect( 'properties', $propName, $value, self::MERGE_STRATEGY_WRITE_ONCE );
126    }
127
128    /** @inheritDoc */
129    public function setExtensionData( string $key, $value ): void {
130        $this->collect( 'extensiondata', $key, $value, self::MERGE_STRATEGY_WRITE_ONCE );
131    }
132
133    /** @inheritDoc */
134    public function setJsConfigVar( string $key, $value ): void {
135        $this->collect( 'jsconfigvars', $key, $value, self::MERGE_STRATEGY_WRITE_ONCE );
136    }
137
138    /** @inheritDoc */
139    public function appendExtensionData(
140        string $key,
141        $value,
142        string $strategy = self::MERGE_STRATEGY_UNION
143    ): void {
144        $this->collect( 'extensiondata', $key, $value, $strategy );
145    }
146
147    /** @inheritDoc */
148    public function appendJsConfigVar(
149        string $key,
150        string $value,
151        string $strategy = self::MERGE_STRATEGY_UNION
152    ): void {
153        $this->collect( 'jsconfigvars', $key, $value, $strategy );
154    }
155
156    /** @inheritDoc */
157    public function addModules( array $modules ): void {
158        $this->appendOutputStrings( CMCSS::MODULE, $modules );
159    }
160
161    /** @inheritDoc */
162    public function addModuleStyles( array $moduleStyles ): void {
163        $this->appendOutputStrings( CMCSS::MODULE_STYLE, $moduleStyles );
164    }
165
166    /** @inheritDoc */
167    public function setLimitReportData( string $key, $value ): void {
168        // XXX maybe need to JSON-encode $value
169        $this->collect( 'limitreportdata', $key, $value, self::MERGE_STRATEGY_WRITE_ONCE );
170    }
171
172    /** @inheritDoc */
173    public function setTOCData( TOCData $tocData ): void {
174        $this->collect( 'tocdata', '', $tocData, self::MERGE_STRATEGY_WRITE_ONCE );
175    }
176
177    /** @inheritDoc */
178    public function addLink( LinkTarget $link, $id = null ): void {
179        # Fragments are stripped when collecting.
180        $link = $link->createFragmentTarget( '' );
181        $type = self::LINKTYPE_LOCAL;
182
183        if ( $link->isExternal() ) {
184            $type = self::LINKTYPE_INTERWIKI;
185        } elseif ( $link->inNamespace( -1 ) ) {
186            $type = self::LINKTYPE_SPECIAL;
187        }
188
189        if ( $type === self::LINKTYPE_LOCAL && $link->getDbkey() === '' ) {
190            // Don't record self links - [[#Foo]]
191            return;
192        }
193        $this->collect(
194            $type,
195            $this->linkToString( $link ),
196            '',
197            self::MERGE_STRATEGY_WRITE_ONCE
198        );
199    }
200
201    /** @inheritDoc */
202    public function addImage( LinkTarget $link, $timestamp = null, $sha1 = null ): void {
203        # Fragments are stripped when collecting.
204        $link = $link->createFragmentTarget( '' );
205        $this->collect(
206            self::LINKTYPE_MEDIA,
207            $this->linkToString( $link ),
208            '',
209            self::MERGE_STRATEGY_WRITE_ONCE
210        );
211    }
212
213    /** @inheritDoc */
214    public function addLanguageLink( LinkTarget $lt ): void {
215        # Fragments are *not* stripped from language links.
216        # Language links are deduplicated by the interwiki prefix
217
218        # Note that, unlike some other types of collected metadata,
219        # language links are 'first wins' and the subsequent entries
220        # for the same language are ignored.
221        if ( $this->get( self::LINKTYPE_LANGUAGE, $lt->getInterwiki(), self::MERGE_STRATEGY_WRITE_ONCE ) !== null ) {
222            return;
223        }
224
225        $this->collect(
226            self::LINKTYPE_LANGUAGE,
227            $lt->getInterwiki(),
228            $this->linkToString( $lt ),
229            self::MERGE_STRATEGY_WRITE_ONCE
230        );
231    }
232
233    /**
234     * Add a dependency on the given template.
235     * @param LinkTarget $link
236     * @param int $page_id
237     * @param int $rev_id
238     */
239    public function addTemplate( LinkTarget $link, int $page_id, int $rev_id ): void {
240        # Fragments are stripped when collecting.
241        $link = $link->createFragmentTarget( '' );
242        // XXX should store the page_id and rev_id
243        $this->collect(
244            self::LINKTYPE_TEMPLATE,
245            $this->linkToString( $link ),
246            '',
247            self::MERGE_STRATEGY_WRITE_ONCE
248        );
249    }
250
251    /**
252     * @see ParserOutput::getLinkList()
253     * @param string $linkType A link type, which should be a constant from
254     *  this class
255     * @return list<array{link:LinkTarget,pageid?:int,revid?:int,sort?:string,time?:string|false,sha1?:string|false}>
256     */
257    public function getLinkList( string $linkType ): array {
258        $result = [];
259        switch ( $linkType ) {
260            case self::LINKTYPE_CATEGORY:
261                foreach ( $this->get( $linkType ) as $link => $sort ) {
262                    $result[] = [
263                        'link' => $this->stringToLink( (string)$link ),
264                        'sort' => $sort,
265                    ];
266                }
267                break;
268            case self::LINKTYPE_LANGUAGE:
269                foreach ( $this->get( $linkType ) as $lang => $link ) {
270                    $result[] = [
271                        'link' => $this->stringToLink( $link ),
272                    ];
273                }
274                break;
275            case self::LINKTYPE_INTERWIKI:
276            case self::LINKTYPE_LOCAL:
277            case self::LINKTYPE_MEDIA:
278            case self::LINKTYPE_SPECIAL:
279            case self::LINKTYPE_TEMPLATE:
280                foreach ( $this->get( $linkType ) as $link => $ignore ) {
281                    $result[] = [
282                        'link' => $this->stringToLink( (string)$link ),
283                    ];
284                }
285                break;
286            default:
287                throw new UnreachableException( "Bad link type: $linkType" );
288        }
289        return $result;
290    }
291
292    /**
293     * Unified internal implementation of metadata collection.
294     * @param string $which Internal string identifying the type of metadata.
295     * @param string $key Key for storage (or '' if this is not relevant)
296     * @param mixed $value Value to store
297     * @param string $strategy "union" or "write-once"
298     */
299    private function collect(
300        string $which, string $key, $value,
301        string $strategy = self::MERGE_STRATEGY_UNION
302    ): void {
303        if ( !array_key_exists( $which, $this->storage ) ) {
304            $this->storage[$which] = [];
305        }
306        if ( !array_key_exists( $key, $this->storage[$which] ) ) {
307            $this->storage[$which][$key] = [ self::MERGE_STRATEGY_KEY => $strategy ];
308            if ( $strategy === self::MERGE_STRATEGY_WRITE_ONCE ) {
309                $this->storage[$which][$key]['value'] = $value;
310                return;
311            }
312        }
313        if ( $this->storage[$which][$key][self::MERGE_STRATEGY_KEY] !== $strategy ) {
314            $this->logger->log(
315                LogLevel::WARNING,
316                "Conflicting strategies for $which $key"
317            );
318            // Destructive update for compatibility; this is deprecated!
319            unset( $this->storage[$which][$key] );
320            $this->collect( $which, $key, $value, $strategy );
321            return;
322        }
323        if ( $strategy === self::MERGE_STRATEGY_WRITE_ONCE ) {
324            if ( ( $this->storage[$which][$key]['value'] ?? null ) === $value ) {
325                return; // already exists with the desired value
326            }
327            $this->logger->log(
328                LogLevel::WARNING,
329                "Multiple writes to a write-once: $which $key"
330            );
331            // Destructive update for compatibility; this is deprecated!
332            unset( $this->storage[$which][$key] );
333            $this->collect( $which, $key, $value, $strategy );
334            return;
335        } elseif ( $strategy === self::MERGE_STRATEGY_UNION ) {
336            if ( !( is_string( $value ) || is_int( $value ) ) ) {
337                throw new \InvalidArgumentException( "Bad value type for $key" . get_debug_type( $value ) );
338            }
339            $this->storage[$which][$key][$value] = true;
340            return;
341        } else {
342            throw new \InvalidArgumentException( "Unknown strategy: $strategy" );
343        }
344    }
345
346    /**
347     * Retrieve values from the collector.
348     * @param string $which Internal string identifying the type of metadata.
349     * @param string|null $key Key for storage (or '' if this is not relevant)
350     * @param string $defaultStrategy Determines whether to return an empty
351     *  array or null for a missing $key
352     * @return mixed
353     */
354    private function get( string $which, ?string $key = null, string $defaultStrategy = self::MERGE_STRATEGY_UNION ) {
355        if ( $key !== null ) {
356            $result = ( $this->storage[$which] ?? [] )[$key] ?? [];
357            $strategy = $result[self::MERGE_STRATEGY_KEY] ?? $defaultStrategy;
358            unset( $result[self::MERGE_STRATEGY_KEY] );
359            if ( $strategy === self::MERGE_STRATEGY_WRITE_ONCE ) {
360                return $result['value'] ?? null;
361            } else {
362                return array_keys( $result );
363            }
364        }
365        $result = [];
366        foreach ( ( $this->storage[$which] ?? [] ) as $key => $ignore ) {
367            $result[$key] = $this->get( $which, (string)$key );
368        }
369        return $result;
370    }
371
372    // @internal introspection methods
373
374    /** @return string[] */
375    public function getModules(): array {
376        return $this->get( 'outputstrings', CMCSS::MODULE );
377    }
378
379    /** @return string[] */
380    public function getModuleStyles(): array {
381        return $this->get( 'outputstrings', CMCSS::MODULE_STYLE );
382    }
383
384    /** @return string[] */
385    public function getJsConfigVars(): array {
386        // This is somewhat unusual, in that we expose the 'set' represenation
387        // as $key => true, instead of just returning array_keys().
388        $result = $this->storage['jsconfigvars'] ?? [];
389        foreach ( $result as $key => &$value ) {
390            $strategy = $value[self::MERGE_STRATEGY_KEY] ?? null;
391            unset( $value[self::MERGE_STRATEGY_KEY] );
392            if ( $strategy === self::MERGE_STRATEGY_WRITE_ONCE ) {
393                $value = array_keys( $value )[0];
394            }
395        }
396        return $result;
397    }
398
399    /** @return list<string> */
400    public function getCategoryNames(): array {
401        return array_map(
402            fn ( $item ) => $item['link']->getDBkey(),
403            $this->getLinkList( self::LINKTYPE_CATEGORY )
404        );
405    }
406
407    /**
408     * @param string $name Category name
409     * @return ?string Sort key
410     */
411    public function getCategorySortKey( string $name ): ?string {
412        $tv = TitleValue::tryNew(
413            14, // NS_CATEGORY
414            $name
415        );
416        return $this->get(
417            self::LINKTYPE_CATEGORY,
418            $this->linkToString( $tv ),
419            self::MERGE_STRATEGY_WRITE_ONCE
420        );
421    }
422
423    /**
424     * @param string $name
425     * @return ?string
426     */
427    public function getPageProperty( string $name ): ?string {
428        return $this->get( 'properties', $name, self::MERGE_STRATEGY_WRITE_ONCE );
429    }
430
431    /**
432     * Return the collected extension data under the given key.
433     * @param string $key
434     * @return mixed|null
435     */
436    public function getExtensionData( string $key ) {
437        return $this->get( 'extensiondata', $key, self::MERGE_STRATEGY_WRITE_ONCE );
438    }
439
440    /**
441     * Return the active output flags.
442     * @return string[]
443     */
444    public function getOutputFlags() {
445        $result = [];
446        foreach ( $this->get( 'outputflags', null ) as $key => $value ) {
447            if ( $value ) {
448                $result[] = $key;
449            }
450        }
451        return $result;
452    }
453
454    /**
455     * Return the collected TOC data, or null if no TOC data was collected.
456     * @return ?TOCData
457     */
458    public function getTOCData(): ?TOCData {
459        return $this->get( 'tocdata', '', self::MERGE_STRATEGY_WRITE_ONCE );
460    }
461
462    /**
463     * Set the content for an indicator.
464     * @param string $name
465     * @param string $content
466     */
467    public function setIndicator( $name, $content ): void {
468        $this->collect( 'indicators', $name, $content, self::MERGE_STRATEGY_WRITE_ONCE );
469    }
470
471    /**
472     * Return a "name" => "content-id" mapping of recorded indicators
473     * @return array
474     */
475    public function getIndicators(): array {
476        return $this->get( 'indicators' );
477    }
478
479    // helper functions for recording LinkTarget objects
480
481    /**
482     * Convert a LinkTarget to a string for storing in the collected metadata.
483     * @param LinkTarget $lt
484     * @return string
485     */
486    private function linkToString( LinkTarget $lt ): string {
487        return implode( '#', [
488            (string)$lt->getNamespace(),
489            $lt->getDBkey(),
490            $lt->getInterwiki(),
491            $lt->getFragment(),
492        ] );
493    }
494
495    /**
496     * Convert a string back into a LinkTarget for retrieval from the
497     * collected metadata.
498     * @param string $s
499     * @return LinkTarget
500     */
501    private function stringToLink( string $s ): LinkTarget {
502        [ $namespace, $dbkey, $interwiki, $fragment ] = explode( '#', $s, 4 );
503        return TitleValue::tryNew( (int)$namespace, $dbkey, $fragment, $interwiki );
504    }
505}