Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.67% covered (danger)
10.67%
16 / 150
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
JSON
10.67% covered (danger)
10.67%
16 / 150
13.33% covered (danger)
13.33%
2 / 15
2373.28
0.00% covered (danger)
0.00%
0 / 1
 getConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 rootValueTable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 objectTable
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 objectRow
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 arrayTable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 valueCell
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 primitiveValue
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 toDOM
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 rootValueTableFrom
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 objectTableFrom
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 objectRowFrom
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 arrayTableFrom
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 valueCellFrom
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 primitiveValueFrom
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 fromDOM
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * This implements the "json" content model as an extension, to allow editing
4 * JSON data structures using Visual Editor.  It represents the JSON
5 * structure as a nested table.
6 */
7
8declare( strict_types = 1 );
9
10namespace Wikimedia\Parsoid\Ext\JSON;
11
12use Wikimedia\Assert\Assert;
13use Wikimedia\Parsoid\Core\ContentModelHandler;
14use Wikimedia\Parsoid\Core\SelserData;
15use Wikimedia\Parsoid\DOM\Document;
16use Wikimedia\Parsoid\DOM\Element;
17use Wikimedia\Parsoid\Ext\DOMUtils;
18use Wikimedia\Parsoid\Ext\ExtensionModule;
19use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
20use Wikimedia\Parsoid\Ext\PHPUtils;
21use Wikimedia\Parsoid\Utils\DOMCompat;
22
23/**
24 * Native Parsoid implementation of the "json" contentmodel.
25 */
26class JSON extends ContentModelHandler implements ExtensionModule {
27    private const PARSE_ERROR_HTML = "<table typeof=\"mw:Error\" data-mw='{\"errors\":[{\"key\":\"bad-json\"}]}'>";
28
29    /** @inheritDoc */
30    public function getConfig(): array {
31        return [
32            'name' => 'JSON content',
33            'contentModels' => [
34                'json' => self::class,
35            ],
36        ];
37    }
38
39    /**
40     * @param Element $parent
41     * @param array|object|string $val
42     */
43    private function rootValueTable( Element $parent, $val ): void {
44        if ( is_array( $val ) ) {
45            // Wrap arrays in another array so they're visually boxed in a
46            // container.  Otherwise they are visually indistinguishable from
47            // a single value.
48            self::arrayTable( $parent, [ $val ] );
49            return;
50        }
51
52        if ( $val && is_object( $val ) ) {
53            self::objectTable( $parent, (array)$val );
54            return;
55        }
56
57        DOMCompat::setInnerHTML( $parent,
58            '<table class="mw-json mw-json-single-value"><tbody><tr><td>' );
59        self::primitiveValue( DOMCompat::querySelector( $parent, 'td' ), $val );
60    }
61
62    private function objectTable( Element $parent, array $val ): void {
63        DOMCompat::setInnerHTML( $parent,
64            '<table class="mw-json mw-json-object"><tbody>' );
65        $tbody = $parent->firstChild->firstChild;
66        DOMUtils::assertElt( $tbody );
67        $keys = array_keys( $val );
68        if ( count( $keys ) ) {
69            foreach ( $val as $k => $v ) {
70                self::objectRow( $tbody, (string)$k, $v );
71            }
72        } else {
73            DOMCompat::setInnerHTML( $tbody,
74                '<tr><td class="mw-json-empty">' );
75        }
76    }
77
78    /**
79     * @param Element $parent
80     * @param ?string $key
81     * @param mixed $val
82     */
83    private function objectRow( Element $parent, ?string $key, $val ): void {
84        $tr = $parent->ownerDocument->createElement( 'tr' );
85        if ( $key !== null ) {
86            $th = $parent->ownerDocument->createElement( 'th' );
87            $th->textContent = $key;
88            $tr->appendChild( $th );
89        }
90        self::valueCell( $tr, $val );
91        $parent->appendChild( $tr );
92    }
93
94    private function arrayTable( Element $parent, array $val ): void {
95        DOMCompat::setInnerHTML( $parent,
96            '<table class="mw-json mw-json-array"><tbody>' );
97        $tbody = $parent->firstChild->firstChild;
98        DOMUtils::assertElt( $tbody );
99        if ( count( $val ) ) {
100            foreach ( $val as $v ) {
101                self::objectRow( $tbody, null, $v );
102            }
103        } else {
104            DOMCompat::setInnerHTML( $tbody,
105                '<tr><td class="mw-json-empty">' );
106        }
107    }
108
109    /**
110     * @param Element $parent
111     * @param mixed $val
112     */
113    private function valueCell( Element $parent, $val ): void {
114        $td = $parent->ownerDocument->createElement( 'td' );
115        if ( is_array( $val ) ) {
116            self::arrayTable( $td, $val );
117        } elseif ( $val && is_object( $val ) ) {
118            self::objectTable( $td, (array)$val );
119        } else {
120            DOMCompat::getClassList( $td )->add( 'value' );
121            self::primitiveValue( $td, $val );
122        }
123        $parent->appendChild( $td );
124    }
125
126    /**
127     * @param Element $parent
128     * @param string|int|bool|null $val
129     */
130    private function primitiveValue( Element $parent, $val ): void {
131        if ( $val === null ) {
132            DOMCompat::getClassList( $parent )->add( 'mw-json-null' );
133            $parent->textContent = 'null';
134            return;
135        } elseif ( is_bool( $val ) ) {
136            DOMCompat::getClassList( $parent )->add( 'mw-json-boolean' );
137            $parent->textContent = $val ? 'true' : 'false';
138            return;
139        } elseif ( is_int( $val ) || is_float( $val ) ) {
140            DOMCompat::getClassList( $parent )->add( 'mw-json-number' );
141        } elseif ( is_string( $val ) ) {
142            DOMCompat::getClassList( $parent )->add( 'mw-json-string' );
143        }
144        $parent->textContent = (string)$val;
145    }
146
147    /**
148     * JSON to HTML.
149     * Implementation matches that from includes/content/JsonContent.php in
150     * mediawiki core, except that we distinguish value types.
151     * @param ParsoidExtensionAPI $extApi
152     * @return Document
153     */
154    public function toDOM( ParsoidExtensionAPI $extApi ): Document {
155        // @phan-suppress-next-line PhanDeprecatedFunction not ready for this yet
156        $jsonText = $extApi->getPageConfig()->getPageMainContent();
157        $document = $extApi->getTopLevelDoc();
158        $body = DOMCompat::getBody( $document );
159
160        // PORT-FIXME: When production moves to PHP 7.3, re-enable this try
161        // catch code
162
163        // try {
164        //     $src = json_decode( $jsonText, false, 6, JSON_THROW_ON_ERROR );
165        //     self::rootValueTable( $body, $src );
166        // } catch ( JsonException $e ) {
167        //     DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML );
168        // }
169
170        $src = json_decode( $jsonText, false, 6 );
171        if ( $src === null && json_last_error() !== JSON_ERROR_NONE ) {
172            DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML );
173        } else {
174            self::rootValueTable( $body, $src );
175        }
176
177        // end of PHP 7.2 compatible error handling code, remove whem enabling
178        // 7.3+ try catch code
179
180        // We're responsible for running the standard DOMPostProcessor on our
181        // resulting document.
182        $extApi->postProcessDOM( $document );
183
184        return $document;
185    }
186
187    /**
188     * RootValueTableFrom
189     * @param Element $el
190     * @return array|false|int|string|null
191     */
192    private function rootValueTableFrom( Element $el ) {
193        if ( DOMUtils::hasClass( $el, 'mw-json-single-value' ) ) {
194            return self::primitiveValueFrom( DOMCompat::querySelector( $el, 'tr > td' ) );
195        } elseif ( DOMUtils::hasClass( $el, 'mw-json-array' ) ) {
196            return self::arrayTableFrom( $el )[0];
197        } else {
198            return self::objectTableFrom( $el );
199        }
200    }
201
202    /**
203     * @param Element $el
204     * @return array
205     */
206    private function objectTableFrom( Element $el ) {
207        Assert::invariant( DOMUtils::hasClass( $el, 'mw-json-object' ),
208            'Expected mw-json-object' );
209        $tbody = $el;
210        if ( $tbody->firstChild ) {
211            $child = $tbody->firstChild;
212            DOMUtils::assertElt( $child );
213            if ( DOMCompat::nodeName( $child ) === 'tbody' ) {
214                $tbody = $child;
215            }
216        }
217        $rows = $tbody->childNodes;
218        $obj = [];
219        $empty = count( $rows ) === 0;
220        if ( !$empty ) {
221            $child = $rows->item( 0 )->firstChild;
222            DOMUtils::assertElt( $child );
223            if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) {
224                $empty = true;
225            }
226        }
227        if ( !$empty ) {
228            for ( $i = 0; $i < count( $rows ); $i++ ) {
229                $item = $rows->item( $i );
230                DOMUtils::assertElt( $item );
231                self::objectRowFrom( $item, $obj, null );
232            }
233        }
234        return $obj;
235    }
236
237    private function objectRowFrom( Element $tr, array &$obj, ?int $key ): void {
238        $td = $tr->firstChild;
239        if ( $key === null ) {
240            $key = $td->textContent;
241            $td = $td->nextSibling;
242        }
243        DOMUtils::assertElt( $td );
244        $obj[$key] = self::valueCellFrom( $td );
245    }
246
247    private function arrayTableFrom( Element $el ): array {
248        Assert::invariant( DOMUtils::hasClass( $el, 'mw-json-array' ),
249            'Expected ms-json-array' );
250        $tbody = $el;
251        if ( $tbody->firstChild ) {
252            $child = $tbody->firstChild;
253            DOMUtils::assertElt( $child );
254            if ( DOMCompat::nodeName( $child ) === 'tbody' ) {
255                $tbody = $child;
256            }
257        }
258        $rows = $tbody->childNodes;
259        $arr = [];
260        $empty = count( $rows ) === 0;
261        if ( !$empty ) {
262            $child = $rows->item( 0 )->firstChild;
263            DOMUtils::assertElt( $child );
264            if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) {
265                $empty = true;
266            }
267        }
268        if ( !$empty ) {
269            for ( $i = 0; $i < count( $rows ); $i++ ) {
270                $item = $rows->item( $i );
271                DOMUtils::assertElt( $item );
272                self::objectRowFrom( $item, $arr, $i );
273            }
274        }
275        return $arr;
276    }
277
278    /**
279     * @param Element $el
280     * @return array|object|false|float|int|string|null
281     */
282    private function valueCellFrom( Element $el ) {
283        Assert::invariant( DOMCompat::nodeName( $el ) === 'td', 'Expected tagName = td' );
284        $table = $el->firstChild;
285        if ( $table instanceof Element ) {
286            if ( DOMUtils::hasClass( $table, 'mw-json-array' ) ) {
287                return self::arrayTableFrom( $table );
288            } elseif ( DOMUtils::hasClass( $table, 'mw-json-object' ) ) {
289                return self::objectTableFrom( $table );
290            }
291        } else {
292            return self::primitiveValueFrom( $el );
293        }
294    }
295
296    /**
297     * @param Element $el
298     * @return false|float|int|string|null
299     */
300    private function primitiveValueFrom( Element $el ) {
301        if ( DOMUtils::hasClass( $el, 'mw-json-null' ) ) {
302            return null;
303        } elseif ( DOMUtils::hasClass( $el, 'mw-json-boolean' ) ) {
304            return str_contains( $el->textContent, 'true' );
305        } elseif ( DOMUtils::hasClass( $el, 'mw-json-number' ) ) {
306            return floatval( $el->textContent );
307        } elseif ( DOMUtils::hasClass( $el, 'mw-json-string' ) ) {
308            return (string)$el->textContent;
309        } else {
310            return null; // shouldn't happen.
311        }
312    }
313
314    /**
315     * DOM to JSON.
316     * @param ParsoidExtensionAPI $extApi
317     * @param ?SelserData $selserData
318     * @return string
319     */
320    public function fromDOM(
321        ParsoidExtensionAPI $extApi, ?SelserData $selserData = null
322    ): string {
323        $body = DOMCompat::getBody( $extApi->getTopLevelDoc() );
324        $t = $body->firstChild;
325        DOMUtils::assertElt( $t );
326        Assert::invariant( $t && DOMCompat::nodeName( $t ) === 'table',
327            'Expected tagName = table' );
328        self::rootValueTableFrom( $t );
329        return PHPUtils::jsonEncode( self::rootValueTableFrom( $t ) );
330    }
331
332}