Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.38% covered (danger)
32.38%
34 / 105
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserTag
32.38% covered (danger)
32.38%
34 / 105
0.00% covered (danger)
0.00%
0 / 8
560.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onGraphTag
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 addTagMetadata
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 finalizeParserOutput
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 buildDivAttributes
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
110
 formatError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 formatStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildHtml
56.67% covered (warning)
56.67%
34 / 60
0.00% covered (danger)
0.00%
0 / 1
48.37
1<?php
2/**
3 *
4 * @license MIT
5 * @file
6 *
7 * @author Dan Andreescu, Yuri Astrakhan, Frédéric Bolduc, Joseph Seddon, Isabelle Hurbain-Palatin
8 */
9
10namespace Graph;
11
12use FormatJson;
13use Language;
14use MediaWiki\Html\Html;
15use MediaWiki\Linker\Linker;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Output\OutputPage;
18use MediaWiki\Page\PageReference;
19use MediaWiki\Parser\ParserOutput;
20use MediaWiki\Status\Status;
21use MediaWiki\Title\Title;
22use Message;
23use Parser;
24use ParserOptions;
25use PPFrame;
26
27class ParserTag {
28    /** Sync with mapSchema.js */
29    private const DEFAULT_WIDTH = 500;
30    private const DEFAULT_HEIGHT = 500;
31
32    /** @var ParserOptions */
33    private $parserOptions;
34
35    /** @var ParserOutput */
36    private $parserOutput;
37
38    /** @var Language */
39    private $language;
40
41    /**
42     * @param Parser $parser
43     * @param ParserOptions $parserOptions
44     * @param ParserOutput $parserOutput
45     */
46    public function __construct(
47        Parser $parser, ParserOptions $parserOptions, ParserOutput $parserOutput
48    ) {
49        $this->parserOptions = $parserOptions;
50        $this->parserOutput = $parserOutput;
51        $this->language = $parser->getTargetLanguage();
52    }
53
54    /**
55     * <graph> parser tag handler.
56     * @param string|null $input
57     * @param array $args
58     * @param Parser $parser
59     * @param PPFrame $frame
60     * @return string
61     */
62    public static function onGraphTag( $input, array $args, Parser $parser, PPFrame $frame ) {
63        $tag = new self( $parser, $parser->getOptions(), $parser->getOutput() );
64        $html = $tag->buildHtml( (string)$input, $parser->getRevisionId(), $args );
65        self::addTagMetadata( $parser->getOutput(), $parser->getPage(), $parser->getOptions()->getIsPreview() );
66        return $html;
67    }
68
69    /**
70     * - Add tracking categories
71     * - Split parser cache for preview, where Graph uses different HTML
72     * @param ParserOutput $parserOutput
73     * @param ?PageReference $pageRef
74     * @param bool $isPreview
75     */
76    public static function addTagMetadata(
77        ParserOutput $parserOutput, ?PageReference $pageRef, bool $isPreview
78    ): void {
79        $tc = MediaWikiServices::getInstance()->getTrackingCategories();
80        if ( $parserOutput->getExtensionData( 'graph_specs_broken' ) ) {
81            $tc->addTrackingCategory( $parserOutput, 'graph-broken-category', $pageRef );
82        }
83        if ( $parserOutput->getExtensionData( 'graph_specs_obsolete' ) ) {
84            $tc->addTrackingCategory( $parserOutput, 'graph-obsolete-category', $pageRef );
85        }
86        $specs = $parserOutput->getExtensionData( 'graph_specs_index' );
87        if ( $specs === null ) {
88            return;
89        }
90        $tc->addTrackingCategory( $parserOutput, 'graph-tracking-category', $pageRef );
91
92        if ( $isPreview ) {
93            $parserOutput->updateCacheExpiry( 0 );
94        }
95    }
96
97    /**
98     * Called on OutputPageParserOutput, handles initializing the client-side logic based on
99     * the graph data collected in the ParserOutput.
100     * @param OutputPage $outputPage
101     * @param ParserOutput $parserOutput
102     */
103    public static function finalizeParserOutput(
104        OutputPage $outputPage, ParserOutput $parserOutput
105    ): void {
106        $specs = $parserOutput->getExtensionData( 'graph_specs_index' );
107        if ( $specs === null ) {
108            return;
109        }
110
111        $outputPage->addModuleStyles( [ 'ext.graph.styles' ] );
112        // We can only load one version of vega lib - either 1 or 2
113        // If the default version is 1, and if any of the graphs need Vega2,
114        // we treat all graphs as Vega2 and load corresponding libraries.
115        // All this should go away once we drop Vega1 support.
116        $liveSpecsIndex = $parserOutput->getExtensionData( 'graph_live_specs_index' );
117        $outputPage->addModules( [ 'ext.graph.loader' ] );
118        $liveSpecs = [];
119        foreach ( $liveSpecsIndex as $hash => $ignore ) {
120            $liveSpecs[$hash] =
121                $parserOutput->getExtensionData( 'graph_live_specs[' . $hash . ']' );
122        }
123        $outputPage->addJsConfigVars( 'wgGraphSpecs', $liveSpecs );
124    }
125
126    /**
127     * @param string $mode lazyload|interactable(click to load)|always(live)|''
128     * @param mixed $data
129     * @param string $hash
130     * @return array
131     */
132    public static function buildDivAttributes( $mode = '', $data = false, $hash = '' ) {
133        $attribs = [ 'class' => 'mw-graph mw-graph-clickable' ];
134
135        if ( is_object( $data ) ) {
136            $width = property_exists( $data, 'width' ) && is_int( $data->width ) ? $data->width : self::DEFAULT_WIDTH;
137            $height =
138                property_exists( $data, 'height' ) && is_int( $data->height ) ? $data->height : self::DEFAULT_HEIGHT;
139            if ( $width && $height ) {
140                $attribs['style'] = "width:{$width}px;height:{$height}px;aspect-ratio:$width/$height";
141            }
142        }
143        if ( $mode ) {
144            $attribs['class'] .= ' mw-graph-' . $mode;
145        }
146        if ( $hash ) {
147            $attribs['data-graph-id'] = $hash;
148        }
149
150        return $attribs;
151    }
152
153    /**
154     * @param Message $msg
155     * @return string
156     */
157    private function formatError( Message $msg ) {
158        $this->parserOutput->setExtensionData( 'graph_specs_broken', true );
159        $error = $msg->inLanguage( $this->language )->parse();
160        return "<span class=\"error\">{$error}</span>";
161    }
162
163    /**
164     * @param Status $status
165     * @return string
166     */
167    private function formatStatus( Status $status ) {
168        return $this->formatError( $status->getMessage( false, false, $this->language ) );
169    }
170
171    /**
172     * Generate HTML output for the <graph> parser tag.
173     * On error, outputs an error message. On success, outputs an empty div with the Vega
174     * specification's sha1 hash in its 'data-graph-id' attribute.
175     * Sets the following keys in the ParserOutput extension data:
176     * - graph_vega2 and graph_specs_obsolete: there is at least one Vega 2 graph on the page
177     * - graph_vega5: there is at least one Vega 5 graph on the page
178     * - graph_specs_index: a list of all Vega spec hashes
179     * - graph_specs[<hash>]: the Vega spec whose sha1 hash is <hash> (note the hash and brackets
180     *   are literally part of the key)
181     * - graph_live_specs_index and graph_live_specs[<hash>]: same thing but for graphs shown
182     *   during page preview.
183     * @param string $jsonText <graph> tag contents; expected to be a JSON Vega definition.
184     * @param int $revid
185     * @param array|null $args <graph> tag attributes:
186     *      title: no longer used?
187     *      fallback: title of a fallback image for noscript
188     *      fallbackWidth: width of the fallback image
189     *      fallbackHeight: height of the fallback image
190     * @return string
191     */
192    public function buildHtml( $jsonText, $revid, $args = null ) {
193        $jsonText = trim( $jsonText );
194        if ( $jsonText === '' ) {
195            return $this->formatError( wfMessage( 'graph-error-empty-json' ) );
196        }
197        $status = FormatJson::parse( $jsonText, FormatJson::TRY_FIXING | FormatJson::STRIP_COMMENTS );
198        if ( !$status->isOK() ) {
199            return $this->formatStatus( $status );
200        }
201
202        $data = $status->getValue();
203        if ( !is_object( $data ) ) {
204            return $this->formatError( wfMessage( 'graph-error-not-vega' ) );
205        }
206
207        // Figure out which vega version to use (TODO drop this)
208        global $wgGraphDefaultVegaVer;
209        if ( property_exists( $data, '$schema' ) ) {
210            if ( !preg_match(
211                // https://vega.github.io/schema/
212                '!https://vega.github.io/schema/(vega|vega-lite)/v(\d)(?:\.\d){0,2}.json!',
213                $data->{'$schema'},
214                $matches
215            ) ) {
216                return $this->formatError( wfMessage( 'graph-error-not-vega' ) );
217            } elseif ( $matches[1] === 'vega-lite' ) {
218                return $this->formatError( wfMessage( 'graph-error-vega-lite-unsupported' ) );
219            }
220            $version = (int)$matches[2];
221        } elseif ( property_exists( $data, 'version' ) && is_numeric( $data->version ) ) {
222            $version = $data->version;
223        } else {
224            $version = $data->version = $wgGraphDefaultVegaVer;
225        }
226        if ( $version === 2 ) {
227            $this->parserOutput->setExtensionData( 'graph_vega2', true );
228            $this->parserOutput->setExtensionData( 'graph_specs_obsolete', true );
229        } elseif ( $version === 5 ) {
230            $this->parserOutput->setExtensionData( 'graph_vega5', true );
231        } else {
232            return $this->formatError( wfMessage( 'graph-error-vega-unsupported-version', $version ) );
233        }
234
235        // Make sure that multiple json blobs that only differ in spacing hash the same
236        $hash = sha1( FormatJson::encode( $data, false, FormatJson::ALL_OK ) );
237
238        // graph_specs is used in ApiGraph; graph_specs_index is also used to set up the
239        // graph tracking category and to gate finalizeParserOutput (we only check whether it's
240        // null or not in those two instances)
241        // TODO: consider merging graph_specs and graph_live_specs to a unique "array" instead of 2
242        $this->parserOutput->appendExtensionData( 'graph_specs_index', $hash );
243        $this->parserOutput->setExtensionData( 'graph_specs[' . $hash . ']', $data );
244
245        // Switching this to false (lazyload), will break cache
246        $alwaysMode = true;
247        /* @phan-suppress-next-line PhanRedundantCondition */
248        if ( $this->parserOptions->getIsPreview() || $alwaysMode ) {
249            $this->parserOutput->appendExtensionData( 'graph_live_specs_index', $hash );
250            $this->parserOutput->setExtensionData( 'graph_live_specs[' . $hash . ']', $data );
251            $attribs = self::buildDivAttributes( 'always', $data, $hash );
252        } else {
253            $attribs = self::buildDivAttributes( 'lazyload', $data, $hash );
254        }
255
256        $isFallback = isset( $args[ 'fallback' ] ) && $args[ 'fallback' ] !== '';
257        if ( $isFallback ) {
258            global $wgThumbLimits, $wgDefaultUserOptions;
259            /* @phan-suppress-next-line PhanTypeArraySuspiciousNullable */
260            $fallbackArgTitle = $args[ 'fallback' ];
261            $services = MediaWikiServices::getInstance();
262            $fallbackParser = $services->getParser();
263            $title = Title::makeTitle( NS_FILE, $fallbackArgTitle );
264            $file = $services->getRepoGroup()->findFile( $title );
265            $imgFallbackParams = [];
266
267            if ( isset( $args[ 'fallbackWidth' ] ) && $args[ 'fallbackWidth' ] > 0 ) {
268                $width = $args[ 'fallbackWidth' ];
269                $imgFallbackParams[ 'width' ] = $width;
270
271            } elseif ( property_exists( $data, 'width' ) ) {
272                $width = is_int( $data->width ) ? $data->width : 0;
273
274                $imgFallbackParams[ 'width' ] = $width;
275            } else {
276                $imgFallbackParams[ 'width' ] = $wgThumbLimits[ $wgDefaultUserOptions[ 'thumbsize' ] ];
277            }
278
279            $imgFallback = Linker::makeImageLink( $fallbackParser, $title, $file, [ '' ], $imgFallbackParams );
280
281            $noSriptAttrs = [
282                'class' => 'mw-graph-noscript',
283            ];
284            // $html will be injected with a <canvas> tag
285            $html = Html::rawElement( 'noscript', $noSriptAttrs, $imgFallback );
286        } else {
287            $attribs[ 'class' ] .= ' mw-graph-nofallback';
288            $html = '';
289        }
290
291        return Html::rawElement( 'div', $attribs, $html );
292    }
293}