Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
13.33% covered (danger)
13.33%
2 / 15
CRAP
10.46% covered (danger)
10.46%
16 / 153
JSON
0.00% covered (danger)
0.00%
0 / 1
13.33% covered (danger)
13.33%
2 / 15
2644.58
10.46% covered (danger)
10.46%
16 / 153
 getConfig
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 rootValueTable
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 10
 objectTable
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 11
 objectRow
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 8
 arrayTable
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 10
 valueCell
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 9
 primitiveValue
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 14
 toDOM
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
9 / 9
 rootValueTableFrom
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 5
 objectTableFrom
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 22
 objectRowFrom
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 7
 arrayTableFrom
0.00% covered (danger)
0.00%
0 / 1
56
0.00% covered (danger)
0.00%
0 / 22
 valueCellFrom
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 9
 primitiveValueFrom
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 9
 fromDOM
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
7 / 7
<?php
/**
 * This implements the "json" content model as an extension, to allow editing
 * JSON data structures using Visual Editor.  It represents the JSON
 * structure as a nested table.
 */
declare( strict_types = 1 );
namespace Wikimedia\Parsoid\Ext\JSON;
use Wikimedia\Assert\Assert;
use Wikimedia\Parsoid\Core\ContentModelHandler;
use Wikimedia\Parsoid\Core\SelserData;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\Ext\DOMUtils;
use Wikimedia\Parsoid\Ext\ExtensionModule;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Ext\PHPUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;
/**
 * Native Parsoid implementation of the "json" contentmodel.
 */
class JSON extends ContentModelHandler implements ExtensionModule {
    private const PARSE_ERROR_HTML = "<table typeof=\"mw:Error\" data-mw='{\"errors\":[{\"key\":\"bad-json\"}]}'>";
    /** @inheritDoc */
    public function getConfig(): array {
        return [
            'name' => 'JSON content',
            'contentModels' => [
                'json' => self::class,
            ],
        ];
    }
    /**
     * @param Element $parent
     * @param array|object|string $val
     */
    private function rootValueTable( Element $parent, $val ): void {
        if ( is_array( $val ) ) {
            // Wrap arrays in another array so they're visually boxed in a
            // container.  Otherwise they are visually indistinguishable from
            // a single value.
            self::arrayTable( $parent, [ $val ] );
            return;
        }
        if ( $val && is_object( $val ) ) {
            self::objectTable( $parent, (array)$val );
            return;
        }
        DOMCompat::setInnerHTML( $parent,
            '<table class="mw-json mw-json-single-value"><tbody><tr><td>' );
        self::primitiveValue( DOMCompat::querySelector( $parent, 'td' ), $val );
    }
    /**
     * @param Element $parent
     * @param array $val
     */
    private function objectTable( Element $parent, array $val ): void {
        DOMCompat::setInnerHTML( $parent,
            '<table class="mw-json mw-json-object"><tbody>' );
        $tbody = $parent->firstChild->firstChild;
        DOMUtils::assertElt( $tbody );
        $keys = array_keys( $val );
        if ( count( $keys ) ) {
            foreach ( $val as $k => $v ) {
                self::objectRow( $tbody, (string)$k, $v );
            }
        } else {
            DOMCompat::setInnerHTML( $tbody,
                '<tr><td class="mw-json-empty">' );
        }
    }
    /**
     * @param Element $parent
     * @param ?string $key
     * @param mixed $val
     */
    private function objectRow( Element $parent, ?string $key, $val ): void {
        $tr = $parent->ownerDocument->createElement( 'tr' );
        if ( $key !== null ) {
            $th = $parent->ownerDocument->createElement( 'th' );
            $th->textContent = $key;
            $tr->appendChild( $th );
        }
        self::valueCell( $tr, $val );
        $parent->appendChild( $tr );
    }
    /**
     * @param Element $parent
     * @param array $val
     */
    private function arrayTable( Element $parent, array $val ): void {
        DOMCompat::setInnerHTML( $parent,
            '<table class="mw-json mw-json-array"><tbody>' );
        $tbody = $parent->firstChild->firstChild;
        DOMUtils::assertElt( $tbody );
        if ( count( $val ) ) {
            foreach ( $val as $v ) {
                self::objectRow( $tbody, null, $v );
            }
        } else {
            DOMCompat::setInnerHTML( $tbody,
                '<tr><td class="mw-json-empty">' );
        }
    }
    /**
     * @param Element $parent
     * @param mixed $val
     */
    private function valueCell( Element $parent, $val ): void {
        $td = $parent->ownerDocument->createElement( 'td' );
        if ( is_array( $val ) ) {
            self::arrayTable( $td, $val );
        } elseif ( $val && is_object( $val ) ) {
            self::objectTable( $td, (array)$val );
        } else {
            DOMCompat::getClassList( $td )->add( 'value' );
            self::primitiveValue( $td, $val );
        }
        $parent->appendChild( $td );
    }
    /**
     * @param Element $parent
     * @param string|int|bool|null $val
     */
    private function primitiveValue( Element $parent, $val ): void {
        if ( $val === null ) {
            DOMCompat::getClassList( $parent )->add( 'mw-json-null' );
            $parent->textContent = 'null';
            return;
        } elseif ( is_bool( $val ) ) {
            DOMCompat::getClassList( $parent )->add( 'mw-json-boolean' );
            $parent->textContent = $val ? 'true' : 'false';
            return;
        } elseif ( is_int( $val ) || is_float( $val ) ) {
            DOMCompat::getClassList( $parent )->add( 'mw-json-number' );
        } elseif ( is_string( $val ) ) {
            DOMCompat::getClassList( $parent )->add( 'mw-json-string' );
        }
        $parent->textContent = (string)$val;
    }
    /**
     * JSON to HTML.
     * Implementation matches that from includes/content/JsonContent.php in
     * mediawiki core, except that we distinguish value types.
     * @param ParsoidExtensionAPI $extApi
     * @return Document
     */
    public function toDOM( ParsoidExtensionAPI $extApi ): Document {
        $jsonText = $extApi->getPageConfig()->getPageMainContent();
        $document = $extApi->getTopLevelDoc();
        $body = DOMCompat::getBody( $document );
        // PORT-FIXME: When production moves to PHP 7.3, re-enable this try
        // catch code
        // try {
        //     $src = json_decode( $jsonText, false, 6, JSON_THROW_ON_ERROR );
        //     self::rootValueTable( $body, $src );
        // } catch ( JsonException $e ) {
        //     DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML );
        // }
        $src = json_decode( $jsonText, false, 6 );
        if ( $src === null && json_last_error() !== JSON_ERROR_NONE ) {
            DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML );
        } else {
            self::rootValueTable( $body, $src );
        }
        // end of PHP 7.2 compatible error handling code, remove whem enabling
        // 7.3+ try catch code
        // We're responsible for running the standard DOMPostProcessor on our
        // resulting document.
        $extApi->postProcessDOM( $document );
        return $document;
    }
    /**
     * RootValueTableFrom
     * @param Element $el
     * @return array|false|int|string|null
     */
    private function rootValueTableFrom( Element $el ) {
        if ( DOMCompat::getClassList( $el )->contains( 'mw-json-single-value' ) ) {
            return self::primitiveValueFrom( DOMCompat::querySelector( $el, 'tr > td' ) );
        } elseif ( DOMCompat::getClassList( $el )->contains( 'mw-json-array' ) ) {
            return self::arrayTableFrom( $el )[0];
        } else {
            return self::objectTableFrom( $el );
        }
    }
    /**
     * @param Element $el
     * @return array
     */
    private function objectTableFrom( Element $el ) {
        Assert::invariant( DOMCompat::getClassList( $el )->contains( 'mw-json-object' ),
            'Expected mw-json-object' );
        $tbody = $el;
        if ( $tbody->firstChild ) {
            $child = $tbody->firstChild;
            DOMUtils::assertElt( $child );
            if ( DOMCompat::nodeName( $child ) === 'tbody' ) {
                $tbody = $child;
            }
        }
        $rows = $tbody->childNodes;
        $obj = [];
        $empty = count( $rows ) === 0;
        if ( !$empty ) {
            $child = $rows->item( 0 )->firstChild;
            DOMUtils::assertElt( $child );
            if ( DOMCompat::getClassList( $child )->contains( 'mw-json-empty' ) ) {
                $empty = true;
            }
        }
        if ( !$empty ) {
            for ( $i = 0; $i < count( $rows ); $i++ ) {
                $item = $rows->item( $i );
                DOMUtils::assertElt( $item );
                self::objectRowFrom( $item, $obj, null );
            }
        }
        return $obj;
    }
    /**
     * @param Element $tr
     * @param array &$obj
     * @param ?int $key
     */
    private function objectRowFrom( Element $tr, array &$obj, ?int $key ) {
        $td = $tr->firstChild;
        if ( $key === null ) {
            $key = $td->textContent;
            $td = $td->nextSibling;
        }
        DOMUtils::assertElt( $td );
        $obj[$key] = self::valueCellFrom( $td );
    }
    /**
     * @param Element $el
     * @return array
     */
    private function arrayTableFrom( Element $el ): array {
        Assert::invariant( DOMCompat::getClassList( $el )->contains( 'mw-json-array' ),
            'Expected ms-json-array' );
        $tbody = $el;
        if ( $tbody->firstChild ) {
            $child = $tbody->firstChild;
            DOMUtils::assertElt( $child );
            if ( DOMCompat::nodeName( $child ) === 'tbody' ) {
                $tbody = $child;
            }
        }
        $rows = $tbody->childNodes;
        $arr = [];
        $empty = count( $rows ) === 0;
        if ( !$empty ) {
            $child = $rows->item( 0 )->firstChild;
            DOMUtils::assertElt( $child );
            if ( DOMCompat::getClassList( $child )->contains( 'mw-json-empty' ) ) {
                $empty = true;
            }
        }
        if ( !$empty ) {
            for ( $i = 0; $i < count( $rows ); $i++ ) {
                $item = $rows->item( $i );
                DOMUtils::assertElt( $item );
                self::objectRowFrom( $item, $arr, $i );
            }
        }
        return $arr;
    }
    /**
     * @param Element $el
     * @return array|object|false|float|int|string|null
     */
    private function valueCellFrom( Element $el ) {
        Assert::invariant( DOMCompat::nodeName( $el ) === 'td', 'Expected tagName = td' );
        $table = $el->firstChild;
        if ( $table instanceof Element ) {
            if ( DOMCompat::getClassList( $table )->contains( 'mw-json-array' ) ) {
                return self::arrayTableFrom( $table );
            } elseif ( DOMCompat::getClassList( $table )->contains( 'mw-json-object' ) ) {
                return self::objectTableFrom( $table );
            }
        } else {
            return self::primitiveValueFrom( $el );
        }
    }
    /**
     * @param Element $el
     * @return false|float|int|string|null
     */
    private function primitiveValueFrom( Element $el ) {
        if ( DOMCompat::getClassList( $el )->contains( 'mw-json-null' ) ) {
            return null;
        } elseif ( DOMCompat::getClassList( $el )->contains( 'mw-json-boolean' ) ) {
            return str_contains( $el->textContent, 'true' );
        } elseif ( DOMCompat::getClassList( $el )->contains( 'mw-json-number' ) ) {
            return floatval( $el->textContent );
        } elseif ( DOMCompat::getClassList( $el )->contains( 'mw-json-string' ) ) {
            return (string)$el->textContent;
        } else {
            return null; // shouldn't happen.
        }
    }
    /**
     * DOM to JSON.
     * @param ParsoidExtensionAPI $extApi
     * @param ?SelserData $selserData
     * @return string
     */
    public function fromDOM(
        ParsoidExtensionAPI $extApi, ?SelserData $selserData = null
    ): string {
        $body = DOMCompat::getBody( $extApi->getTopLevelDoc() );
        $t = $body->firstChild;
        DOMUtils::assertElt( $t );
        Assert::invariant( $t && DOMCompat::nodeName( $t ) === 'table',
            'Expected tagName = table' );
        self::rootValueTableFrom( $t );
        return PHPUtils::jsonEncode( self::rootValueTableFrom( $t ) );
    }
}