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