Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.28% covered (danger)
12.28%
28 / 228
5.00% covered (danger)
5.00%
2 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMDataUtils
12.28% covered (danger)
12.28%
28 / 228
5.00% covered (danger)
5.00%
2 / 40
5314.98
0.00% covered (danger)
0.00%
0 / 1
 getBag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCodec
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepareDoc
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 prepareChildDoc
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 stashObjectInDoc
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 noAttrs
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getNodeData
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 setNodeData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataParsoid
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setDataParsoid
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataMwI18n
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setDataMwI18n
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataNodeI18n
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setDataNodeI18n
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDataAttrI18n
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setDataAttrI18n
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDataAttrI18nNames
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getDataParsoidDiff
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setDataParsoidDiff
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataMw
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setDataMw
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 validDataMw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validDataMwI18n
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJSONAttribute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 setJSONAttribute
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setShadowInfo
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setShadowInfoIfModified
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 addNormalizedAttribute
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPageBundle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storeInPageBundle
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
6.25
 getCodecHints
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 injectPageBundle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 extractPageBundle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 visitAndLoadDataAttribs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadDataAttribs
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 usedIdIndex
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 visitAndStoreDataAttribs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 storeDataAttribs
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
 cloneNode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 fixClonedData
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Composer\Semver\Semver;
7use stdClass;
8use Wikimedia\Assert\Assert;
9use Wikimedia\Assert\UnreachableException;
10use Wikimedia\JsonCodec\Hint;
11use Wikimedia\JsonCodec\JsonCodec;
12use Wikimedia\Parsoid\Config\Env;
13use Wikimedia\Parsoid\Core\PageBundle;
14use Wikimedia\Parsoid\DOM\Document;
15use Wikimedia\Parsoid\DOM\Element;
16use Wikimedia\Parsoid\DOM\Node;
17use Wikimedia\Parsoid\NodeData\DataBag;
18use Wikimedia\Parsoid\NodeData\DataMw;
19use Wikimedia\Parsoid\NodeData\DataMwI18n;
20use Wikimedia\Parsoid\NodeData\DataParsoid;
21use Wikimedia\Parsoid\NodeData\I18nInfo;
22use Wikimedia\Parsoid\NodeData\NodeData;
23use Wikimedia\Parsoid\NodeData\TempData;
24
25/**
26 * These helpers pertain to HTML and data attributes of a node.
27 */
28class DOMDataUtils {
29    public const DATA_OBJECT_ATTR_NAME = 'data-object-id';
30
31    /**
32     * Return the dynamic "bag" property of a Document.
33     * @param Document $doc
34     * @return DataBag
35     */
36    public static function getBag( Document $doc ): DataBag {
37        // This is a dynamic property; it is not declared.
38        // All references go through here so we can suppress phan's complaint.
39        // @phan-suppress-next-line PhanUndeclaredProperty
40        return $doc->bag;
41    }
42
43    /**
44     * Return the JsonCodec used for rich attributes in a Document.
45     * @param Document $doc
46     * @return JsonCodec
47     */
48    public static function getCodec( Document $doc ): JsonCodec {
49        // This is a dynamic property; it is not declared.
50        // All references go through here so we can suppress phan's complaint.
51        // @phan-suppress-next-line PhanUndeclaredProperty
52        return $doc->codec;
53    }
54
55    public static function prepareDoc( Document $doc ): void {
56        // `bag` is a deliberate dynamic property; see DOMDataUtils::getBag()
57        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
58        $doc->bag = new DataBag();
59        // `codec` is a deliberate dynamic property; see DOMDataUtils::getCodec()
60        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
61        $doc->codec = new JsonCodec();
62
63        // Cache the head and body.
64        DOMCompat::getHead( $doc );
65        DOMCompat::getBody( $doc );
66    }
67
68    /**
69     * @param Document $topLevelDoc
70     * @param Document $childDoc
71     */
72    public static function prepareChildDoc( Document $topLevelDoc, Document $childDoc ) {
73        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
74        Assert::invariant( $topLevelDoc->bag instanceof DataBag, 'doc bag not set' );
75        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
76        $childDoc->bag = $topLevelDoc->bag;
77    }
78
79    /**
80     * Stash $obj in $doc and return an id for later retrieval
81     * @param Document $doc
82     * @param NodeData $obj
83     * @return int
84     */
85    public static function stashObjectInDoc( Document $doc, NodeData $obj ): int {
86        return self::getBag( $doc )->stashObject( $obj );
87    }
88
89    /**
90     * Does this node have any attributes?
91     * @param Element $node
92     * @return bool
93     */
94    public static function noAttrs( Element $node ): bool {
95        // The 'xmlns' attribute is "invisible" T235295
96        if ( $node->hasAttribute( 'xmlns' ) ) {
97            return false;
98        }
99        $numAttrs = count( $node->attributes );
100        return $numAttrs === 0 ||
101            ( $numAttrs === 1 && $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) );
102    }
103
104    /**
105     * Get data object from a node.
106     *
107     * @param Element $node node
108     * @return NodeData
109     */
110    public static function getNodeData( Element $node ): NodeData {
111        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
112            // Initialized on first request
113            $dataObject = new NodeData;
114            self::setNodeData( $node, $dataObject );
115            return $dataObject;
116        }
117
118        $nodeId = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
119        if ( $nodeId !== null ) {
120            $dataObject = self::getBag( $node->ownerDocument )->getObject( (int)$nodeId );
121        } else {
122            $dataObject = null; // Make phan happy
123        }
124        Assert::invariant( isset( $dataObject ), 'Bogus nodeId given!' );
125        if ( isset( $dataObject->storedId ) ) {
126            throw new UnreachableException(
127                'Trying to fetch node data without loading!' .
128                // If this node's data-object id is different from storedId,
129                // it will indicate that the data-parsoid object was shared
130                // between nodes without getting cloned. Useful for debugging.
131                'Node id: ' . $nodeId .
132                'Stored data: ' . PHPUtils::jsonEncode( $dataObject )
133            );
134        }
135        return $dataObject;
136    }
137
138    /**
139     * Set node data.
140     *
141     * @param Element $node node
142     * @param NodeData $data data
143     */
144    public static function setNodeData( Element $node, NodeData $data ): void {
145        $nodeId = self::stashObjectInDoc( $node->ownerDocument, $data );
146        $node->setAttribute( self::DATA_OBJECT_ATTR_NAME, (string)$nodeId );
147    }
148
149    /**
150     * Get data parsoid info from a node.
151     *
152     * @param Element $node node
153     * @return DataParsoid
154     */
155    public static function getDataParsoid( Element $node ): DataParsoid {
156        $data = self::getNodeData( $node );
157        $data->parsoid ??= new DataParsoid;
158        return $data->parsoid;
159    }
160
161    /**
162     * Set data parsoid info on a node.
163     *
164     * @param Element $node node
165     * @param DataParsoid $dp data-parsoid
166     */
167    public static function setDataParsoid( Element $node, DataParsoid $dp ): void {
168        $data = self::getNodeData( $node );
169        $data->parsoid = $dp;
170    }
171
172    /**
173     * Returns the i18n information of a node. This is in private access because it shouldn't
174     * typically be used directly; instead getDataNodeI18n and getDataAttrI18n should be used.
175     * @param Element $node
176     * @return DataMwI18n|null
177     */
178    private static function getDataMwI18n( Element $node ): ?DataMwI18n {
179        $data = self::getNodeData( $node );
180        // We won't set a default value for this property
181        return $data->i18n ?? null;
182    }
183
184    /**
185     * Sets the i18n information of a node. This is in private access because it shouldn't
186     * typically be used directly; instead setDataNodeI18n and setDataAttrI18n should be used.
187     */
188    private static function setDataMwI18n( Element $node, DataMwI18n $i18n ) {
189        $data = self::getNodeData( $node );
190        $data->i18n = $i18n;
191    }
192
193    /**
194     * Retrieves internationalization (i18n) information of a node (typically for localization)
195     * @param Element $node
196     * @return ?I18nInfo
197     */
198    public static function getDataNodeI18n( Element $node ): ?I18nInfo {
199        $data = self::getNodeData( $node );
200        // We won't set a default value for this property
201        if ( !isset( $data->i18n ) ) {
202            return null;
203        }
204        return $data->i18n->getSpanInfo();
205    }
206
207    /**
208     * Sets internationalization (i18n) information of a node, used for later localization
209     */
210    public static function setDataNodeI18n( Element $node, I18nInfo $i18n ) {
211        $data = self::getNodeData( $node );
212        $data->i18n ??= new DataMwI18n();
213        $data->i18n->setSpanInfo( $i18n );
214    }
215
216    /**
217     * Retrieves internationalization (i18n) information of an attribute value (typically for
218     * localization)
219     * @param Element $node
220     * @param string $name
221     * @return ?I18nInfo
222     */
223    public static function getDataAttrI18n( Element $node, string $name ): ?I18nInfo {
224        $data = self::getNodeData( $node );
225        // We won't set a default value for this property
226        if ( !isset( $data->i18n ) ) {
227            return null;
228        }
229        return $data->i18n->getAttributeInfo( $name );
230    }
231
232    /**
233     * Sets internationalization (i18n) information of a attribute value, used for later
234     * localization
235     * @param Element $node
236     * @param string $name
237     * @param I18nInfo $i18n
238     */
239    public static function setDataAttrI18n( Element $node, string $name, I18nInfo $i18n ) {
240        $data = self::getNodeData( $node );
241        $data->i18n ??= new DataMwI18n();
242        $data->i18n->setAttributeInfo( $name, $i18n );
243    }
244
245    /**
246     * @param Element $node
247     * @return array
248     */
249    public static function getDataAttrI18nNames( Element $node ): array {
250        $data = self::getNodeData( $node );
251        // We won't set a default value for this property
252        if ( !isset( $data->i18n ) ) {
253            return [];
254        }
255        return $data->i18n->getAttributeNames();
256    }
257
258    /**
259     * Get data diff info from a node.
260     *
261     * @param Element $node node
262     * @return ?stdClass
263     */
264    public static function getDataParsoidDiff( Element $node ): ?stdClass {
265        $data = self::getNodeData( $node );
266        // We won't set a default value for this property
267        return $data->parsoid_diff ?? null;
268    }
269
270    /**
271     * Set data diff info on a node.
272     *
273     * @param Element $node node
274     * @param ?stdClass $diffObj data-parsoid-diff object
275     */
276    public static function setDataParsoidDiff( Element $node, ?stdClass $diffObj ): void {
277        $data = self::getNodeData( $node );
278        $data->parsoid_diff = $diffObj;
279    }
280
281    /**
282     * Get data meta wiki info from a node.
283     *
284     * @param Element $node node
285     * @return DataMw
286     */
287    public static function getDataMw( Element $node ): DataMw {
288        $data = self::getNodeData( $node );
289        $data->mw ??= new DataMw;
290        return $data->mw;
291    }
292
293    /**
294     * Set data meta wiki info from a node.
295     *
296     * @param Element $node node
297     * @param ?DataMw $dmw data-mw
298     */
299    public static function setDataMw( Element $node, ?DataMw $dmw ): void {
300        $data = self::getNodeData( $node );
301        $data->mw = $dmw;
302    }
303
304    /**
305     * Check if there is meta wiki info in a node.
306     *
307     * @param Element $node node
308     * @return bool
309     */
310    public static function validDataMw( Element $node ): bool {
311        return (array)self::getDataMw( $node ) !== [];
312    }
313
314    /**
315     * Check if there is i18n info on a node (for the node or its attributes)
316     * @param Element $node
317     * @return bool
318     */
319    public static function validDataMwI18n( Element $node ): bool {
320        return self::getDataMwI18n( $node ) !== null;
321    }
322
323    /**
324     * Get an object from a JSON-encoded XML attribute on a node.
325     *
326     * @param Element $node node
327     * @param string $name name
328     * @param mixed $defaultVal
329     * @return mixed
330     */
331    public static function getJSONAttribute( Element $node, string $name, $defaultVal ) {
332        $attVal = DOMCompat::getAttribute( $node, $name );
333        if ( $attVal === null ) {
334            return $defaultVal;
335        }
336        $decoded = PHPUtils::jsonDecode( $attVal, false );
337        if ( $decoded !== null ) {
338            return $decoded;
339        } else {
340            error_log( 'ERROR: Could not decode attribute-val ' . $attVal .
341                ' for ' . $name . ' on node ' . DOMCompat::nodeName( $node ) );
342            return $defaultVal;
343        }
344    }
345
346    /**
347     * Set a attribute on a node with a JSON-encoded object.
348     *
349     * @param Element $node node
350     * @param string $name Name of the attribute.
351     * @param mixed $obj value of the attribute to
352     */
353    public static function setJSONAttribute( Element $node, string $name, $obj ): void {
354        $val = $obj === [] ? '{}' : PHPUtils::jsonEncode( $obj );
355        $node->setAttribute( $name, $val );
356    }
357
358    /**
359     * Set shadow info on a node; similar to the method on tokens.
360     * Records a key = value pair in data-parsoid['a'] property.
361     *
362     * This is effectively a call of 'setShadowInfoIfModified' except
363     * there is no original value, so by definition, $val is modified.
364     *
365     * @param Element $node node
366     * @param string $name Name of the attribute.
367     * @param mixed $val val
368     */
369    public static function setShadowInfo( Element $node, string $name, $val ): void {
370        $dp = self::getDataParsoid( $node );
371        $dp->a ??= [];
372        $dp->sa ??= [];
373        $dp->a[$name] = $val;
374    }
375
376    /**
377     * Set shadow info on a node; similar to the method on tokens.
378     *
379     * If the new value ($val) for the key ($name) is different from the
380     * original value ($origVal):
381     * - the new value is recorded in data-parsoid->a and
382     * - the original value is recorded in data-parsoid->sa
383     *
384     * @param Element $node node
385     * @param string $name Name of the attribute.
386     * @param mixed $val val
387     * @param mixed $origVal original value (null is a valid value)
388     * @param bool $skipOrig
389     */
390    public static function setShadowInfoIfModified(
391        Element $node, string $name, $val, $origVal, bool $skipOrig = false
392    ): void {
393        if ( !$skipOrig && ( $val === $origVal || $origVal === null ) ) {
394            return;
395        }
396        $dp = self::getDataParsoid( $node );
397        $dp->a ??= [];
398        $dp->sa ??= [];
399        // FIXME: This is a hack to not overwrite already shadowed info.
400        // We should either fix the call site that depends on this
401        // behaviour to do an explicit check, or double down on this
402        // by porting it to the token method as well.
403        if ( !$skipOrig && !array_key_exists( $name, $dp->a ) ) {
404            $dp->sa[$name] = $origVal;
405        }
406        $dp->a[$name] = $val;
407    }
408
409    /**
410     * Set an attribute and shadow info to a node.
411     * Similar to the method on tokens
412     *
413     * @param Element $node node
414     * @param string $name Name of the attribute.
415     * @param mixed $val value
416     * @param mixed $origVal original value
417     * @param bool $skipOrig
418     */
419    public static function addNormalizedAttribute(
420        Element $node, string $name, $val, $origVal, bool $skipOrig = false
421    ): void {
422        if ( $name === 'id' ) {
423            DOMCompat::setIdAttribute( $node, $val );
424        } else {
425            $node->setAttribute( $name, $val );
426        }
427        self::setShadowInfoIfModified( $node, $name, $val, $origVal, $skipOrig );
428    }
429
430    /**
431     * Get this document's pagebundle object
432     * @param Document $doc
433     * @return PageBundle
434     */
435    public static function getPageBundle( Document $doc ): PageBundle {
436        return self::getBag( $doc )->getPageBundle();
437    }
438
439    /**
440     * Removes the `data-*` attribute from a node, and migrates the data to the
441     * document's JSON store. Generates a unique id with the following format:
442     * ```
443     * mw<base64-encoded counter>
444     * ```
445     * but attempts to keep user defined ids.
446     *
447     * TODO: Note that $data is effective a partial PageBundle containing
448     * only the 'parsoid' and 'mw' properties.
449     *
450     * @param Element $node node
451     * @param Env $env environment
452     * @param stdClass $data data
453     * @param array $idIndex Index of used id attributes in the DOM
454     */
455    public static function storeInPageBundle(
456        Element $node, Env $env, stdClass $data, array $idIndex
457    ): void {
458        $hints = self::getCodecHints();
459        $uid = DOMCompat::getAttribute( $node, 'id' );
460        $document = $node->ownerDocument;
461        $pb = self::getPageBundle( $document );
462        $codec = self::getCodec( $document );
463        $docDp = &$pb->parsoid;
464        $origId = $uid;
465        if ( $uid !== null && array_key_exists( $uid, $docDp['ids'] ) ) {
466            $uid = null;
467            $env->log( 'info', 'Wikitext for this page has duplicate ids: ' . $origId );
468        }
469        if ( $uid === '' ) {
470            $uid = null;
471            $env->log( 'info', 'Bogus empty id' );
472        }
473        if ( $uid === null ) {
474            do {
475                $docDp['counter'] += 1;
476                // PORT-FIXME: NOTE that we aren't updating the idIndex here because
477                // we are generating unique ids that will not conflict. In any case,
478                // the idIndex is a workaround for the PHP DOM's issues and we might
479                // switch out of this in the future anyway.
480                $uid = 'mw' . PHPUtils::counterToBase64( $docDp['counter'] );
481            } while ( isset( $idIndex[$uid] ) );
482            self::addNormalizedAttribute( $node, 'id', $uid, $origId );
483        }
484        // Convert from DataParsoid/DataMw objects to associative array
485        $docDp['ids'][$uid] = $codec->toJsonArray( $data->parsoid, $hints['data-parsoid'] );
486        if ( isset( $data->mw ) ) {
487            $pb->mw['ids'][$uid] = $codec->toJsonArray( $data->mw, $hints['data-mw'] );
488        }
489    }
490
491    /**
492     * Helper function to create static Hint objects for JsonCodec.
493     * @return array<Hint>
494     */
495    public static function getCodecHints(): array {
496        static $hints = null;
497        if ( $hints === null ) {
498            $hints = [
499                'data-parsoid' => Hint::build( DataParsoid::class, Hint::ALLOW_OBJECT ),
500                'data-mw' => Hint::build( DataMw::class, Hint::ALLOW_OBJECT ),
501            ];
502        }
503        return $hints;
504    }
505
506    /**
507     * @param Document $doc doc
508     * @param PageBundle $pb object
509     */
510    public static function injectPageBundle( Document $doc, PageBundle $pb ): void {
511        $script = DOMUtils::appendToHead( $doc, 'script', [
512            'id' => 'mw-pagebundle',
513            'type' => 'application/x-mw-pagebundle',
514        ] );
515        $script->appendChild( $doc->createTextNode( $pb->encodeForHeadElement() ) );
516    }
517
518    /**
519     * @param Document $doc doc
520     * @return stdClass|null
521     */
522    public static function extractPageBundle( Document $doc ): ?stdClass {
523        $pb = null;
524        $dpScriptElt = DOMCompat::getElementById( $doc, 'mw-pagebundle' );
525        if ( $dpScriptElt ) {
526            $dpScriptElt->parentNode->removeChild( $dpScriptElt );
527            // we actually want arrays in the page bundle rather than stdClasses; but we still
528            // want to access the object properties
529            $pb = (object)PHPUtils::jsonDecode( $dpScriptElt->textContent );
530        }
531        return $pb;
532    }
533
534    /**
535     * Walk DOM from node downward calling loadDataAttribs
536     *
537     * @param Node $node node
538     * @param array $options options
539     */
540    public static function visitAndLoadDataAttribs( Node $node, array $options = [] ): void {
541        DOMUtils::visitDOM( $node, [ self::class, 'loadDataAttribs' ], $options );
542    }
543
544    /**
545     * These are intended be used on a document after post-processing, so that
546     * the underlying .dataobject is transparently applied (in the store case)
547     * and reloaded (in the load case), rather than worrying about keeping
548     * the attributes up-to-date throughout that phase.  For the most part,
549     * using this.ppTo* should be sufficient and using these directly should be
550     * avoided.
551     *
552     * @param Node $node node
553     * @param array $options options
554     */
555    public static function loadDataAttribs( Node $node, array $options ): void {
556        if ( !( $node instanceof Element ) ) {
557            return;
558        }
559        // Reset the node data object's stored state, since we're reloading it
560        self::setNodeData( $node, new NodeData );
561        $codec = self::getCodec( $node->ownerDocument );
562        $dataParsoidAttr = DOMCompat::getAttribute( $node, 'data-parsoid' );
563        $dp = $codec->newFromJsonString(
564            $dataParsoidAttr ?? '{}', self::getCodecHints()['data-parsoid']
565        );
566        if ( !empty( $options['markNew'] ) ) {
567            $dp->setTempFlag( TempData::IS_NEW, $dataParsoidAttr === null );
568        }
569        self::setDataParsoid( $node, $dp );
570        $node->removeAttribute( 'data-parsoid' );
571
572        $dataMwAttr = DOMCompat::getAttribute( $node, 'data-mw' );
573        $dmw = $dataMwAttr === null ? null :
574            $codec->newFromJsonString( $dataMwAttr, self::getCodecHints()['data-mw'] );
575        self::setDataMw( $node, $dmw );
576        $node->removeAttribute( 'data-mw' );
577
578        $dpd = self::getJSONAttribute( $node, 'data-parsoid-diff', null );
579        self::setDataParsoidDiff( $node, $dpd );
580        $node->removeAttribute( 'data-parsoid-diff' );
581        $dataI18n = DOMCompat::getAttribute( $node, 'data-mw-i18n' );
582        if ( $dataI18n !== null ) {
583            $i18n = DataMwI18n::fromJson( PHPUtils::jsonDecode( $dataI18n, true ) );
584            self::setDataMwI18n( $node, $i18n );
585            $node->removeAttribute( 'data-mw-i18n' );
586        }
587    }
588
589    /**
590     * Builds an index of id attributes seen in the DOM
591     * @param Node $node
592     * @return array
593     */
594    public static function usedIdIndex( Node $node ): array {
595        $index = [];
596        DOMUtils::visitDOM( DOMCompat::getBody( $node->ownerDocument ),
597            static function ( Node $n, ?array $options = null ) use ( &$index ) {
598                if ( $n instanceof Element ) {
599                    $id = DOMCompat::getAttribute( $n, 'id' );
600                    if ( $id !== null ) {
601                        $index[$id] = true;
602                    }
603                }
604            },
605            []
606        );
607        return $index;
608    }
609
610    /**
611     * Walk DOM from node downward calling storeDataAttribs
612     *
613     * @param Node $node node
614     * @param array $options options
615     */
616    public static function visitAndStoreDataAttribs( Node $node, array $options = [] ): void {
617        // PORT-FIXME: storeDataAttribs calls storeInPageBundle which calls getElementById.
618        // PHP's `getElementById` implementation is broken, and we work around that by
619        // using Zest which uses XPath. So, getElementById call can be O(n) and calling it
620        // on on every element of the DOM via vistDOM here makes it O(n^2) instead of O(n).
621        // So, we work around that by building an index and avoiding getElementById entirely
622        // in storeInPageBundle.
623        if ( !empty( $options['storeInPageBundle'] ) ) {
624            $options['idIndex'] = self::usedIdIndex( $node );
625        }
626        DOMUtils::visitDOM( $node, [ self::class, 'storeDataAttribs' ], $options );
627    }
628
629    /**
630     * Copy data attributes from the bag to either JSON-encoded attributes on
631     * each node, or the page bundle, erasing the data-object-id attributes.
632     *
633     * @param Node $node node
634     * @param ?array $options options
635     *   - discardDataParsoid: Discard DataParsoid objects instead of storing them
636     *   - keepTmp: Preserve DataParsoid::$tmp
637     *   - storeInPageBundle: If true, data will be stored in the page bundle
638     *     instead of data-parsoid and data-mw.
639     *   - env: The Env object required for various features
640     *   - idIndex: Array of used ID attributes
641     */
642    public static function storeDataAttribs( Node $node, ?array $options = null ): void {
643        $hints = self::getCodecHints();
644        $options ??= [];
645        if ( !( $node instanceof Element ) ) {
646            return;
647        }
648        Assert::invariant( empty( $options['discardDataParsoid'] ) || empty( $options['keepTmp'] ),
649            'Conflicting options: discardDataParsoid and keepTmp are both enabled.' );
650        $codec = self::getCodec( $node->ownerDocument );
651        $dp = self::getDataParsoid( $node );
652        $discardDataParsoid = !empty( $options['discardDataParsoid'] );
653        if ( $dp->getTempFlag( TempData::IS_NEW ) ) {
654            // Only necessary to support the cite extension's getById,
655            // that's already been loaded once.
656            //
657            // This is basically a hack to ensure that DOMUtils.isNewElt
658            // continues to work since we effectively rely on the absence
659            // of data-parsoid to identify new elements. But, loadDataAttribs
660            // creates an empty {} if one doesn't exist. So, this hack
661            // ensures that a loadDataAttribs + storeDataAttribs pair don't
662            // dirty the node by introducing an empty data-parsoid attribute
663            // where one didn't exist before.
664            //
665            // Ideally, we'll find a better solution for this edge case later.
666            $discardDataParsoid = true;
667        }
668        $data = null;
669        if ( !$discardDataParsoid ) {
670            if ( empty( $options['keepTmp'] ) ) {
671                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
672                unset( $dp->tmp );
673            }
674
675            if ( !empty( $options['storeInPageBundle'] ) ) {
676                $data ??= new stdClass;
677                $data->parsoid = $dp;
678            } else {
679                $node->setAttribute(
680                    'data-parsoid',
681                    PHPUtils::jsonEncode(
682                        $codec->toJsonArray( $dp, $hints['data-parsoid'] )
683                    )
684                );
685            }
686        }
687
688        // Strip invalid data-mw attributes
689        if ( self::validDataMw( $node ) ) {
690            if (
691                !empty( $options['storeInPageBundle'] ) && isset( $options['env'] ) &&
692                // The pagebundle didn't have data-mw before 999.x
693                Semver::satisfies( $options['env']->getOutputContentVersion(), '^999.0.0' )
694            ) {
695                $data ??= new stdClass;
696                $data->mw = self::getDataMw( $node );
697            } else {
698                $node->setAttribute(
699                    'data-mw',
700                    PHPUtils::jsonEncode(
701                        $codec->toJsonArray( self::getDataMw( $node ), $hints['data-mw'] )
702                    )
703                );
704            }
705        }
706
707        if ( self::validDataMwI18n( $node ) ) {
708            self::setJSONAttribute( $node, 'data-mw-i18n', self::getDataMwI18n( $node ) );
709        }
710
711        // Store pagebundle
712        if ( $data !== null ) {
713            self::storeInPageBundle( $node, $options['env'], $data, $options['idIndex'] );
714        }
715
716        // Indicate that this node's data has been stored so that if we try
717        // to access it after the fact we're aware and remove the attribute
718        // since it's no longer needed.
719        $nd = self::getNodeData( $node );
720        $id = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
721        $nd->storedId = $id !== null ? intval( $id ) : null;
722        $node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
723    }
724
725    /**
726     * Clones a node and its data bag
727     * @param Element $elt
728     * @param bool $deep
729     * @return Element
730     */
731    public static function cloneNode( Element $elt, bool $deep ): Element {
732        $clone = $elt->cloneNode( $deep );
733        '@phan-var Element $clone'; // @var Element $clone
734        // We do not need to worry about $deep because a shallow clone does not have child nodes,
735        // so it's always cloning data on the cloned tree (which may be empty).
736        self::fixClonedData( $clone );
737        return $clone;
738    }
739
740    /**
741     * Recursively fixes cloned data from $elt: to avoid conflicts of element IDs, we clone the
742     * data and set it in the node with a new element ID (which setNodeData does).
743     * @param Element $elt
744     */
745    private static function fixClonedData( Element $elt ) {
746        if ( $elt->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
747            self::setNodeData( $elt, self::getNodeData( $elt )->cloneNodeData() );
748        }
749        foreach ( $elt->childNodes as $child ) {
750            if ( $child instanceof Element ) {
751                self::fixClonedData( $child );
752            }
753        }
754    }
755}