/**
* JSON Content Model
*
+ * This class requires the root structure to be an object (not primitives or arrays).
+ *
* @file
*
* @author Ori Livneh <ori@wikimedia.org>
*/
class JsonContent extends TextContent {
- public function __construct( $text, $modelId = CONTENT_MODEL_JSON ) {
- parent::__construct( $text, $modelId );
+ /**
+ * @since 1.25
+ * @var Status
+ */
+ protected $jsonParse;
+
+ /**
+ * @param string $text JSON
+ */
+ public function __construct( $text ) {
+ parent::__construct( $text, CONTENT_MODEL_JSON );
}
/**
* Decodes the JSON into a PHP associative array.
- * @return array
+ *
+ * @deprecated since 1.25 Use getData instead.
+ * @return array|null
*/
public function getJsonData() {
+ wfDeprecated( __METHOD__, '1.25' );
return FormatJson::decode( $this->getNativeData(), true );
}
/**
- * @return bool Whether content is valid JSON.
+ * Decodes the JSON string into a PHP object.
+ *
+ * @return Status
+ */
+ public function getData() {
+ if ( $this->jsonParse === null ) {
+ $this->jsonParse = FormatJson::parse( $this->getNativeData() );
+ }
+ return $this->jsonParse;
+ }
+
+ /**
+ * @return bool Whether content is valid.
*/
public function isValid() {
- return $this->getJsonData() !== null;
+ return $this->getData()->isGood() && is_object( $this->getData()->getValue() );
}
/**
- * Pretty-print JSON
+ * Pretty-print JSON.
+ *
+ * If called before validation, it may return JSON "null".
*
- * @return bool|null|string
+ * @return string
*/
public function beautifyJSON() {
- $decoded = $this->getJsonData();
- if ( !is_array( $decoded ) ) {
- return null;
- }
- return FormatJson::encode( $decoded, true );
-
+ return FormatJson::encode( $this->getData()->getValue(), true );
}
/**
* Beautifies JSON prior to save.
+ *
* @param Title $title Title
* @param User $user User
* @param ParserOptions $popts
* @return JsonContent
*/
public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) {
+ // FIXME: WikiPage::doEditContent invokes PST before validation. As such, native data
+ // may be invalid (though PST result is discarded later in that case).
+ if ( !$this->isValid() ) {
+ return $this;
+ }
+
return new static( $this->beautifyJSON() );
}
/**
- * Set the HTML and add the appropriate styles
- *
+ * Set the HTML and add the appropriate styles.
*
* @param Title $title
* @param int $revId
protected function fillParserOutput( Title $title, $revId,
ParserOptions $options, $generateHtml, ParserOutput &$output
) {
- if ( $generateHtml ) {
- $output->setText( $this->objectTable( $this->getJsonData() ) );
+ // FIXME: WikiPage::doEditContent generates parser output before validation.
+ // As such, native data may be invalid (though output is discarded later in that case).
+ if ( $generateHtml && $this->isValid() ) {
+ $output->setText( $this->objectTable( $this->getData()->getValue() ) );
$output->addModuleStyles( 'mediawiki.content.json' );
} else {
$output->setText( '' );
}
}
+
/**
- * Constructs an HTML representation of a JSON object.
- * @param array $mapping
+ * Construct an HTML representation of a JSON object.
+ *
+ * Called recursively via valueCell().
+ *
+ * @param stdClass $mapping
* @return string HTML
*/
protected function objectTable( $mapping ) {
$rows = array();
+ $empty = true;
foreach ( $mapping as $key => $val ) {
$rows[] = $this->objectRow( $key, $val );
+ $empty = false;
}
- return Xml::tags( 'table', array( 'class' => 'mw-json' ),
- Xml::tags( 'tbody', array(), join( "\n", $rows ) )
+ if ( $empty ) {
+ $rows[] = Html::rawElement( 'tr', array(),
+ Html::element( 'td', array( 'class' => 'mw-json-empty' ),
+ wfMessage( 'content-json-empty-object' )->text()
+ )
+ );
+ }
+ return Html::rawElement( 'table', array( 'class' => 'mw-json' ),
+ Html::rawElement( 'tbody', array(), join( "\n", $rows ) )
);
}
/**
- * Constructs HTML representation of a single key-value pair.
+ * Construct HTML representation of a single key-value pair.
* @param string $key
* @param mixed $val
* @return string HTML.
*/
protected function objectRow( $key, $val ) {
$th = Xml::elementClean( 'th', array(), $key );
- if ( is_array( $val ) ) {
- $td = Xml::tags( 'td', array(), self::objectTable( $val ) );
- } else {
- if ( is_string( $val ) ) {
- $val = '"' . $val . '"';
- } else {
- $val = FormatJson::encode( $val );
- }
+ $td = self::valueCell( $val );
+ return Html::rawElement( 'tr', array(), $th . $td );
+ }
+
+ /**
+ * Constructs an HTML representation of a JSON array.
+ *
+ * Called recursively via valueCell().
+ *
+ * @param array $mapping
+ * @return string HTML
+ */
+ protected function arrayTable( $mapping ) {
+ $rows = array();
+ $empty = true;
- $td = Xml::elementClean( 'td', array( 'class' => 'value' ), $val );
+ foreach ( $mapping as $val ) {
+ $rows[] = $this->arrayRow( $val );
+ $empty = false;
}
+ if ( $empty ) {
+ $rows[] = Html::rawElement( 'tr', array(),
+ Html::element( 'td', array( 'class' => 'mw-json-empty' ),
+ wfMessage( 'content-json-empty-array' )->text()
+ )
+ );
+ }
+ return Html::rawElement( 'table', array( 'class' => 'mw-json' ),
+ Html::rawElement( 'tbody', array(), join( "\n", $rows ) )
+ );
+ }
- return Xml::tags( 'tr', array(), $th . $td );
+ /**
+ * Construct HTML representation of a single array value.
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function arrayRow( $val ) {
+ $td = self::valueCell( $val );
+ return Html::rawElement( 'tr', array(), $td );
}
+ /**
+ * Construct HTML representation of a single value.
+ * @param mixed $val
+ * @return string HTML.
+ */
+ protected function valueCell( $val ) {
+ if ( is_object( $val ) ) {
+ return Html::rawElement( 'td', array(), self::objectTable( $val ) );
+ }
+ if ( is_array( $val ) ) {
+ return Html::rawElement( 'td', array(), self::arrayTable( $val ) );
+ }
+ if ( is_string( $val ) ) {
+ $val = '"' . $val . '"';
+ } else {
+ $val = FormatJson::encode( $val );
+ }
+
+ return Xml::elementClean( 'td', array( 'class' => 'value' ), $val );
+ }
}
"content-model-text": "plain text",
"content-model-javascript": "JavaScript",
"content-model-css": "CSS",
+ "content-model-json": "JSON",
+ "content-json-empty-object": "Empty object",
+ "content-json-empty-array": "Empty array",
"duplicate-args-category": "Pages using duplicate arguments in template calls",
"duplicate-args-category-desc": "The page contains template calls that use duplicates of arguments, such as <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> or <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
"expensive-parserfunction-warning": "<strong>Warning:</strong> This page contains too many expensive parser function calls.\n\nIt should have less than $2 {{PLURAL:$2|call|calls}}, there {{PLURAL:$1|is now $1 call|are now $1 calls}}.",
"content-model-text": "Name for the plain text content model, used when decribing what type of content a page contains.\n\nThis message is substituted in:\n*{{msg-mw|Bad-target-model}}\n*{{msg-mw|Content-not-allowed-here}}\n{{Identical|Plain text}}",
"content-model-javascript": "Name for the JavaScript content model, used when decribing what type of content a page contains.\n\nThis message is substituted in:\n*{{msg-mw|Bad-target-model}}\n*{{msg-mw|Content-not-allowed-here}}",
"content-model-css": "Name for the CSS content model, used when decribing what type of content a page contains.\n\nThis message is substituted in:\n*{{msg-mw|Bad-target-model}}\n*{{msg-mw|Content-not-allowed-here}}",
+ "content-model-json": "Name for the JSON content model, used when decribing what type of content a page contains.\n\nThis message is substituted in:\n*{{msg-mw|Bad-target-model}}\n*{{msg-mw|Content-not-allowed-here}}",
+ "content-json-empty-object": "Used to represent an object with no properties on a JSON content model page.",
+ "content-json-empty-array": "Used to represent an array with no values on a JSON content model page.",
"duplicate-args-category": "This message is used as a category name for a [[mw:Special:MyLanguage/Help:Tracking categories|tracking category]] where pages are placed automatically if they contain template calls that use duplicates of arguments, such as <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> or <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
"duplicate-args-category-desc": "Duplicate arguments category description. Shown on [[Special:TrackingCategories]].\n\nSee also:\n* {{msg-mw|Duplicate-args-category}}",
"expensive-parserfunction-warning": "On some (expensive) [[MetaWikipedia:Help:ParserFunctions|parser functions]] (e.g. <code><nowiki>{{#ifexist:}}</nowiki></code>) there is a limit of how many times it may be used. This is an error message shown when the limit is exceeded.\n\nParameters:\n* $1 - the current number of parser function calls\n* $2 - the allowed number of parser function calls\nSee also [[:mw:Manual:$wgExpensiveParserFunctionLimit|$wgExpensiveParserFunctionLimit in the MediaWiki manual]].\n\nSee also:\n* {{msg-mw|Expensive-parserfunction-category}}",
padding: 0.5em 1em;
}
-.mw-json td {
- background-color: #eee;
- font-style: italic;
-}
-
.mw-json .value {
background-color: #dcfae3;
font-family: monospace, monospace;
white-space: pre-wrap;
}
+.mw-json-empty {
+ background-color: #fff;
+ font-style: italic;
+}
+
.mw-json tr {
margin-bottom: 0.5em;
+ background-color: #eee;
}
.mw-json th {
*/
class JsonContentTest extends MediaWikiLangTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( 'wgWellFormedXml', true );
+ }
+
public static function provideValidConstruction() {
return array(
- array( 'foo', CONTENT_MODEL_JSON, false, null ),
- array( FormatJson::encode( array() ), CONTENT_MODEL_JSON, true, array() ),
- array( FormatJson::encode( array( 'foo' ) ), CONTENT_MODEL_JSON, true, array( 'foo' ) ),
+ array( 'foo', false, null ),
+ array( '{}', true, (object)array() ),
+ array( '{ "0": "bar" }', true, (object)array( 'bar' ) ),
);
}
/**
* @dataProvider provideValidConstruction
*/
- public function testValidConstruct( $text, $modelId, $isValid, $expected ) {
- $obj = new JsonContent( $text, $modelId );
+ public function testIsValid( $text, $isValid, $expected ) {
+ $obj = new JsonContent( $text, CONTENT_MODEL_JSON );
$this->assertEquals( $isValid, $obj->isValid() );
- $this->assertEquals( $expected, $obj->getJsonData() );
+ $this->assertEquals( $expected, $obj->getData()->getValue() );
}
public static function provideDataToEncode() {
return array(
- array( array() ),
- array( array( 'foo' ) ),
- array( array( 'foo', 'bar' ) ),
- array( array( 'baz' => 'foo', 'bar' ) ),
- array( array( 'baz' => 1000, 'bar' ) ),
+ array(
+ // Round-trip empty array
+ '[]',
+ '[]',
+ ),
+ array(
+ // Round-trip empty object
+ '{}',
+ '{}',
+ ),
+ array(
+ // Round-trip empty array/object (nested)
+ '{ "foo": {}, "bar": [] }',
+ "{\n \"foo\": {},\n \"bar\": []\n}",
+ ),
+ array(
+ '{ "foo": "bar" }',
+ "{\n \"foo\": \"bar\"\n}",
+ ),
+ array(
+ '{ "foo": 1000 }',
+ "{\n \"foo\": 1000\n}",
+ ),
+ array(
+ '{ "foo": 1000, "0": "bar" }',
+ "{\n \"foo\": 1000,\n \"0\": \"bar\"\n}",
+ ),
);
}
/**
* @dataProvider provideDataToEncode
*/
- public function testBeautifyUsesFormatJson( $data ) {
- $obj = new JsonContent( FormatJson::encode( $data ) );
- $this->assertEquals( FormatJson::encode( $data, true ), $obj->beautifyJSON() );
+ public function testBeautifyJson( $input, $beautified ) {
+ $obj = new JsonContent( $input );
+ $this->assertEquals( $beautified, $obj->beautifyJSON() );
}
/**
* @dataProvider provideDataToEncode
*/
- public function testPreSaveTransform( $data ) {
- $obj = new JsonContent( FormatJson::encode( $data ) );
+ public function testPreSaveTransform( $input, $transformed ) {
+ $obj = new JsonContent( $input );
$newObj = $obj->preSaveTransform( $this->getMockTitle(), $this->getMockUser(), $this->getMockParserOptions() );
- $this->assertTrue( $newObj->equals( new JsonContent( FormatJson::encode( $data, true ) ) ) );
+ $this->assertTrue( $newObj->equals( new JsonContent( $transformed ) ) );
}
private function getMockTitle() {
public static function provideDataAndParserText() {
return array(
array(
- array(),
- '<table class="mw-json"><tbody></tbody></table>'
+ (object)array(),
+ '<table class="mw-json"><tbody><tr><td class="mw-json-empty">Empty object</td></tr></tbody></table>'
),
array(
- array( 'foo' ),
+ (object)array( 'foo' ),
'<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr></tbody></table>'
),
array(
- array( 'foo', 'bar' ),
+ (object)array( 'foo', 'bar' ),
'<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr>' .
"\n" .
'<tr><th>1</th><td class="value">"bar"</td></tr></tbody></table>'
),
array(
- array( 'baz' => 'foo', 'bar' ),
+ (object)array( 'baz' => 'foo', 'bar' ),
'<table class="mw-json"><tbody><tr><th>baz</th><td class="value">"foo"</td></tr>' .
"\n" .
'<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>'
),
array(
- array( 'baz' => 1000, 'bar' ),
+ (object)array( 'baz' => 1000, 'bar' ),
'<table class="mw-json"><tbody><tr><th>baz</th><td class="value">1000</td></tr>' .
"\n" .
'<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>'
),
array(
- array( '<script>alert("evil!")</script>'),
+ (object)array( '<script>alert("evil!")</script>'),
'<table class="mw-json"><tbody><tr><th>0</th><td class="value">"<script>alert("evil!")</script>"</td></tr></tbody></table>',
),
);