Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 114
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParsoidDomProcessor
0.00% covered (danger)
0.00%
0 / 114
0.00% covered (danger)
0.00%
0 / 7
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wtPostprocess
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
110
 processKartographerNode
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
272
 updateSrc
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 updateSrcSet
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 updateUrl
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 addCategory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Kartographer\Tag;
4
5use Kartographer\SimpleStyleParser;
6use MediaWiki\Category\TrackingCategories;
7use MediaWiki\Config\Config;
8use MediaWiki\Json\FormatJson;
9use MediaWiki\Page\PageReferenceValue;
10use MediaWiki\Parser\ParserOutput;
11use MediaWiki\Parser\ParserOutputStringSets;
12use MediaWiki\Title\Title;
13use stdClass;
14use Wikimedia\Parsoid\DOM\Element;
15use Wikimedia\Parsoid\DOM\Node;
16use Wikimedia\Parsoid\Ext\DOMDataUtils;
17use Wikimedia\Parsoid\Ext\DOMProcessor;
18use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
19use Wikimedia\Parsoid\Utils\DOMTraverser;
20
21/**
22 * @license MIT
23 */
24class ParsoidDomProcessor extends DOMProcessor {
25
26    public function __construct(
27        private readonly Config $config,
28        private readonly TrackingCategories $trackingCategories,
29    ) {
30    }
31
32    /** @inheritDoc */
33    public function wtPostprocess( ParsoidExtensionAPI $extApi, Node $root, array $options ): void {
34        if ( !( $root instanceof Element ) ) {
35            return;
36        }
37
38        // Optimization: skip postprocessing if there are no kartographer
39        // nodes.  Use the presence of the kartographer modules as a hint
40        // that there are kartographer nodes on this page.
41        $metadata = $extApi->getMetadata();
42        if ( $metadata instanceof ParserOutput ) {
43            $kartMods = [
44                'ext.kartographer.link',
45                'ext.kartographer.staticframe',
46                'ext.kartographer.frame'
47            ];
48            if ( array_intersect( $kartMods, $metadata->getModules() ) === [] ) {
49                return;
50            }
51        }
52
53        $state = [
54            'broken' => 0,
55            'interactiveGroups' => [],
56            'requestedGroups' => [],
57            'counters' => [],
58            'maplinks' => 0,
59            'mapframes' => 0,
60            'data' => [],
61        ];
62
63        $mapServer = $this->config->get( 'KartographerMapServer' );
64        $extApi->getMetadata()->addModuleStyles( [ 'ext.kartographer.style' ] );
65        $extApi->getMetadata()->appendOutputStrings(
66            ParserOutputStringSets::EXTRA_CSP_DEFAULT_SRC->value,
67            [ $mapServer ]
68        );
69
70        $traverser = new DOMTraverser( false, true );
71        $traverser->addHandler( null, function ( $node ) use ( &$state, $extApi ) {
72            if ( $node instanceof Element && $node->hasAttribute( 'data-mw-kartographer' ) ) {
73                $this->processKartographerNode( $node, $extApi, $state );
74            }
75            return true;
76        } );
77        $traverser->traverse( $extApi->getSiteConfig(), $root );
78
79        if ( $state['broken'] > 0 ) {
80            $this->addCategory( $extApi, 'kartographer-broken-category' );
81        }
82        if ( $state['maplinks'] + $state['mapframes'] > $state['broken'] ) {
83            $this->addCategory( $extApi, 'kartographer-tracking-category' );
84        }
85
86        $interactive = $state['interactiveGroups'];
87        $state['interactiveGroups'] = array_keys( $state['interactiveGroups'] );
88        $state['requestedGroups'] = array_keys( $state['requestedGroups'] );
89        $state['parsoidIntVersion'] = $this->config->get( 'KartographerParsoidVersion' );
90        $extApi->getMetadata()->setExtensionData( 'kartographer', $state );
91
92        foreach ( $interactive as $req ) {
93            if ( !isset( $state['data'][$req] ) ) {
94                $state['data'][$req] = [];
95            }
96        }
97        $extApi->getMetadata()->setJsConfigVar( 'wgKartographerLiveData', $state['data'] ?? [] );
98    }
99
100    private function processKartographerNode( Element $kartnode, ParsoidExtensionAPI $extApi, array &$state ): void {
101        $tagName = $kartnode->getAttribute( 'data-mw-kartographer' ) ?? '';
102        if ( $tagName !== '' ) {
103            $state[$tagName . 's' ]++;
104        }
105
106        $markerStr = $kartnode->getAttribute( 'data-kart' );
107        $kartnode->removeAttribute( 'data-kart' );
108        if ( $markerStr === 'error' ) {
109            $state['broken']++;
110            return;
111        }
112        $marker = json_decode( $markerStr ?? '' );
113
114        $state['requestedGroups'] = array_merge( $state['requestedGroups'], $marker->showGroups ?? [] );
115        if ( $tagName === ParsoidMapFrame::TAG ) {
116            $state['interactiveGroups'] = array_merge( $state['interactiveGroups'], $marker->showGroups ?? [] );
117        }
118
119        if ( !$marker || !$marker->geometries || !$marker->geometries[0] instanceof stdClass ) {
120            return;
121        }
122        [ $counter, $props ] = SimpleStyleParser::updateMarkerSymbolCounters( $marker->geometries,
123            $state['counters'] );
124        if ( $tagName === ParsoidMapLink::TAG && $counter ) {
125            if ( DOMDataUtils::getDataMw( $kartnode )->getExtAttrib( 'text' ) === null ) {
126                $text = $extApi->getTopLevelDoc()->createTextNode( $counter );
127                $kartnode->replaceChild( $text, $kartnode->firstChild );
128            }
129        }
130
131        $data = $marker->geometries;
132
133        if ( $counter ) {
134            // If we have a counter, we update the marker data prior to encoding the groupId, and we remove
135            // the (previously computed) groupId from showGroups
136            if ( ( $marker->groupId[0] ?? '' ) === '_' ) {
137                // TODO unclear if this is necessary or if we could simply set it to [].
138                $marker->showGroups = array_values( array_diff( $marker->showGroups, [ $marker->groupId ] ) );
139                $marker->groupId = null;
140            }
141        }
142
143        $groupId = $marker->groupId ?? null;
144        if ( $groupId === null ) {
145            // This hash calculation MUST be the same as in LegacyTagHandler::updateState
146            $groupId = '_' . sha1( FormatJson::encode( $marker->geometries, false, FormatJson::ALL_OK ) );
147            $marker->groupId = $groupId;
148            $marker->showGroups[] = $groupId;
149            $kartnode->setAttribute( 'data-overlays', FormatJson::encode( $marker->showGroups ) );
150            $img = $kartnode->firstChild;
151            // this should always be the case, but let make phan aware of it
152            if ( $img instanceof Element ) {
153                $this->updateSrc( $img, $groupId, $extApi );
154                $this->updateSrcSet( $img, $groupId, $extApi );
155            }
156        }
157
158        // There is no way to ever add anything to a private group starting with `_`
159        if ( isset( $state['data'][$groupId] ) && !str_starts_with( $groupId, '_' ) ) {
160            $state['data'][$groupId] = array_merge( $state['data'][$groupId], $data );
161        } else {
162            $state['data'][$groupId] = $data;
163        }
164    }
165
166    private function updateSrc( Element $firstChild, string $groupId, ParsoidExtensionAPI $extApi ): void {
167        $src = $firstChild->getAttribute( 'src' ) ?? '';
168        if ( $src !== '' ) {
169            $src = $this->updateUrl( $src, $extApi, $groupId );
170            $firstChild->setAttribute( 'src', $src );
171        }
172    }
173
174    private function updateSrcSet( Element $firstChild, string $groupId, ParsoidExtensionAPI $extApi ): void {
175        $srcset = $firstChild->getAttribute( 'srcset' ) ?? '';
176        if ( $srcset !== '' ) {
177            $arr = explode( ', ', $srcset );
178            $srcsets = [];
179            foreach ( $arr as $plop ) {
180                $toks = explode( ' ', $plop );
181                $toks[0] = $this->updateUrl( $toks[0], $extApi, $groupId );
182                $srcsets[] = implode( ' ', $toks );
183            }
184            $firstChild->setAttribute( 'srcset', implode( ', ', $srcsets ) );
185        }
186    }
187
188    private function updateUrl( string $src, ParsoidExtensionAPI $extApi, string $groupId ): string {
189        $url = explode( '?', $src );
190        $attrs = wfCgiToArray( $url[1] );
191
192        $linkTarget = $extApi->getPageConfig()->getLinkTarget();
193        $pagetitle = Title::newFromLinkTarget( $linkTarget )->getPrefixedDBkey();
194        $revisionId = $extApi->getPageConfig()->getRevisionId();
195        $attrs = array_merge( $attrs,
196            MapFrameAttributeGenerator::getUrlAttrs( $this->config, $pagetitle, $revisionId, [ $groupId ], 'parsoid' )
197        );
198
199        return $url[0] . '?' . wfArrayToCgi( $attrs );
200    }
201
202    /**
203     * Add category to the page, from its key
204     */
205    private function addCategory( ParsoidExtensionAPI $extApi, string $category ): void {
206        $linkTarget = $extApi->getPageConfig()->getLinkTarget();
207        $pageRef = PageReferenceValue::localReference(
208            $linkTarget->getNamespace(), $linkTarget->getDBkey()
209        );
210        $cat = $this->trackingCategories->resolveTrackingCategory( $category, $pageRef );
211        if ( $cat ) {
212            $extApi->getMetadata()->addCategory( $cat );
213        }
214    }
215
216}