Implement Gadgets definition namespace repo

Implements:
* Gadget definition content and content handler
* Basic validation for gadget definition content
* GadgetDefinitionNamespace implementation of GadgetRepo
* DataUpdates upon editing/deletion of Gadget definition pages
* EditFilterMerged hook for improved error messages
* 'GadgetsRepoClass' option to switch GadgetRepo implementation used
* Lazy-load the GadgetResourceLoaderModule class so we don't need to
load each individual gadget object unless its needed

Note that Special:Gadgets's export feature intentionally doesn't work
yet, and will be fixed in a follow up patch.

Bug: T106177
Change-Id: Ib11db5fb0f7b46793bfa956cf1367f1dc1059b1c
This commit is contained in:
Kunal Mehta 2015-08-02 23:37:32 -07:00 committed by Kaldari
parent f52d81a245
commit 519f30355e
18 changed files with 841 additions and 99 deletions

View file

@ -144,16 +144,12 @@ class GadgetHooks {
public static function registerModules( &$resourceLoader ) {
$repo = GadgetRepo::singleton();
$ids = $repo->getGadgetIds();
if ( !$ids ) {
return true;
}
foreach ( $ids as $id ) {
$g = $repo->getGadget( $id );
$module = $g->getModule();
if ( $module ) {
$resourceLoader->register( $g->getModuleName(), $module );
}
$resourceLoader->register( Gadget::getModuleName( $id ), array(
'class' => 'GadgetResourceLoaderModule',
'id' => $id,
) );
}
return true;
@ -180,11 +176,15 @@ class GadgetHooks {
*/
$user = $out->getUser();
foreach ( $ids as $id ) {
$gadget = $repo->getGadget( $id );
try {
$gadget = $repo->getGadget( $id );
} catch ( InvalidArgumentException $e ) {
continue;
}
if ( $gadget->isEnabled( $user ) && $gadget->isAllowed( $user ) ) {
if ( $gadget->hasModule() ) {
$out->addModuleStyles( $gadget->getModuleName() );
$out->addModules( $gadget->getModuleName() );
$out->addModuleStyles( Gadget::getModuleName( $gadget->getName() ) );
$out->addModules( Gadget::getModuleName( $gadget->getName() ) );
}
if ( $gadget->getLegacyScripts() ) {
@ -213,6 +213,95 @@ class GadgetHooks {
);
}
/**
* Valid gadget definition page after content is modified
*
* @param IContextSource $context
* @param Content $content
* @param Status $status
* @param string $summary
* @throws Exception
* @return bool
*/
public static function onEditFilterMergedContent( $context, $content, $status, $summary ) {
$title = $context->getTitle();
if ( !$title->inNamespace( NS_GADGET_DEFINITION ) ) {
return true;
}
if ( !$content instanceof GadgetDefinitionContent ) {
// This should not be possible?
throw new Exception( "Tried to save non-GadgetDefinitionContent to {$title->getPrefixedText()}" );
}
$status = $content->validate();
if ( !$status->isGood() ) {
$status->merge( $status );
return false;
}
return true;
}
/**
* After a new page is created in the Gadget definition namespace,
* invalidate the list of gadget ids
*
* @param WikiPage $page
*/
public static function onPageContentInsertComplete( WikiPage $page ) {
if ( $page->getTitle()->inNamespace( NS_GADGET_DEFINITION ) ) {
$repo = GadgetRepo::singleton();
if ( $repo instanceof GadgetDefinitionNamespaceRepo ) {
$repo->purgeGadgetIdsList();
}
}
}
/**
* Mark the Title as having a content model of javascript or css for pages
* in the Gadget namespace based on their file extension
*
* @param Title $title
* @param string $model
* @return bool
*/
public static function onContentHandlerDefaultModelFor( Title $title, &$model ) {
if ( $title->inNamespace( NS_GADGET ) ) {
preg_match( '!\.(css|js)$!u', $title->getText(), $ext );
$ext = isset( $ext[1] ) ? $ext[1] : '';
switch ( $ext ) {
case 'js':
$model = 'javascript';
return false;
case 'css':
$model = 'css';
return false;
}
}
return true;
}
/**
* Set the CodeEditor language for Gadget definition pages. It already
* knows the language for Gadget: namespace pages.
*
* @param Title $title
* @param string $lang
* @return bool
*/
public static function onCodeEditorGetPageLanguage( Title $title, &$lang ) {
if ( $title->hasContentModel( 'GadgetDefinition' ) ) {
$lang = 'json';
return false;
}
return true;
}
/**
* UnitTestsList hook handler
* @param array $files

View file

@ -63,6 +63,47 @@ class Gadget {
}
}
/**
* Create a object based on the metadata in a GadgetDefinitionContent object
*
* @param string $id
* @param GadgetDefinitionContent $content
* @return Gadget
*/
public static function newFromDefinitionContent( $id, GadgetDefinitionContent $content ) {
$data = $content->getAssocArray();
$prefixGadgetNs = function ( $page ) {
return 'Gadget:' . $page;
};
$info = array(
'name' => $id,
'resourceLoaded' => true,
'requiredRights' => $data['settings']['rights'],
'onByDefault' => $data['settings']['default'],
'hidden' => $data['settings']['hidden'],
'requiredSkins' => $data['settings']['skins'],
'category' => $data['settings']['category'],
'scripts' => array_map( $prefixGadgetNs, $data['module']['scripts'] ),
'styles' => array_map( $prefixGadgetNs, $data['module']['styles'] ),
'dependencies' => $data['module']['dependencies'],
'messages' => $data['module']['messages'],
'position' => $data['module']['position'],
);
return new self( $info );
}
/**
* Get a placeholder object to use if a gadget doesn't exist
*
* @param string $id name
* @return Gadget
*/
public static function newEmptyGadget( $id ) {
return new self( array( 'name' => $id ) );
}
/**
* Whether the provided gadget id is valid
*
@ -70,7 +111,7 @@ class Gadget {
* @return bool
*/
public static function isValidGadgetID( $id ) {
return strlen( $id ) > 0 && ResourceLoader::isValidModuleName( "ext.gadget.$id" );
return strlen( $id ) > 0 && ResourceLoader::isValidModuleName( Gadget::getModuleName( $id ) );
}
@ -103,10 +144,11 @@ class Gadget {
}
/**
* @return String: Name of ResourceLoader module for this gadget
* @param string $id Name of gadget
* @return string Name of ResourceLoader module for the gadget
*/
public function getModuleName() {
return "ext.gadget.{$this->name}";
public static function getModuleName( $id ) {
return "ext.gadget.{$id}";
}
/**
@ -127,7 +169,7 @@ class Gadget {
*/
public function isAllowed( $user ) {
return count( array_intersect( $this->requiredRights, $user->getRights() ) ) == count( $this->requiredRights )
&& ( !count( $this->requiredSkins ) || in_array( $user->getOption( 'skin' ), $this->requiredSkins ) );
&& ( $this->requiredSkins === true || !count( $this->requiredSkins ) || in_array( $user->getOption( 'skin' ), $this->requiredSkins ) );
}
/**
@ -168,14 +210,14 @@ class Gadget {
}
/**
* @return Array: Array of pages with JS not prefixed with namespace
* @return Array: Array of pages with JS (including namespace)
*/
public function getScripts() {
return $this->scripts;
}
/**
* @return Array: Array of pages with CSS not prefixed with namespace
* @return Array: Array of pages with CSS (including namespace)
*/
public function getStyles() {
return $this->styles;
@ -189,34 +231,10 @@ class Gadget {
}
/**
* Returns module for ResourceLoader, see getModuleName() for its name.
* If our gadget has no scripts or styles suitable for RL, false will be returned.
* @return Mixed: GadgetResourceLoaderModule or false
* @return array
*/
public function getModule() {
$pages = array();
foreach ( $this->styles as $style ) {
$pages['MediaWiki:' . $style] = array( 'type' => 'style' );
}
if ( $this->supportsResourceLoader() ) {
foreach ( $this->scripts as $script ) {
$pages['MediaWiki:' . $script] = array( 'type' => 'script' );
}
}
if ( !count( $pages ) ) {
return null;
}
return new GadgetResourceLoaderModule(
$pages,
$this->dependencies,
$this->targets,
$this->position,
$this->messages
);
public function getTargets() {
return $this->targets;
}
/**

6
README
View file

@ -35,3 +35,9 @@ See http://www.mediawiki.org/wiki/Extension:Gadgets#Usage
* Gadgets do not apply to Special:Preferences, Special:UserLogin and
Special:ResetPass so users can always disable any broken gadgets they
may have enabled, and malicious gadgets will be unable to steal passwords.
== Configuration settings ==
* $wgGadgetsRepoClass configures which GadgetRepo implementation will be used
to source gadgets from. Currently, "MediaWikiGadgetsDefinitionRepo" is the
recommended setting and default. The "GadgetDefinitionNamespaceRepo" is not
ready for production usage yet.

View file

@ -44,6 +44,7 @@ class SpecialGadgets extends SpecialPage {
return;
}
$output->disallowUserJs();
$lang = $this->getLanguage();
$langSuffix = "";
if ( $lang->getCode() != $wgContLang->getCode() ) {
@ -140,21 +141,25 @@ class SpecialGadgets extends SpecialPage {
);
}
$skins = array();
$validskins = Skin::getSkinNames();
foreach ( $gadget->getRequiredSkins() as $skinid ) {
if ( isset( $validskins[$skinid] ) ) {
$skins[] = $this->msg( "skinname-$skinid" )->plain();
} else {
$skins[] = $skinid;
$requiredSkins = $gadget->getRequiredSkins();
// $requiredSkins can be an array or true (if all skins are supported)
if ( is_array( $requiredSkins ) ) {
$skins = array();
$validskins = Skin::getSkinNames();
foreach ( $requiredSkins as $skinid ) {
if ( isset( $validskins[$skinid] ) ) {
$skins[] = $this->msg( "skinname-$skinid" )->plain();
} else {
$skins[] = $skinid;
}
}
if ( count( $skins ) ) {
$output->addHTML(
'<br />' .
$this->msg( 'gadgets-required-skins', $lang->commaList( $skins ) )
->numParams( count( $skins ) )->parse()
);
}
}
if ( count( $skins ) ) {
$output->addHTML(
'<br />' .
$this->msg( 'gadgets-required-skins', $lang->commaList( $skins ) )
->numParams( count( $skins ) )->parse()
);
}
if ( $gadget->isOnByDefault() ) {
@ -191,7 +196,7 @@ class SpecialGadgets extends SpecialPage {
$exportList = "MediaWiki:gadget-$gadget\n";
foreach ( $g->getScriptsAndStyles() as $page ) {
$exportList .= "MediaWiki:$page\n";
$exportList .= "$page\n";
}
$output->addHTML( Html::openElement( 'form', array( 'method' => 'get', 'action' => $wgScript ) )

View file

@ -25,7 +25,8 @@
"constant": "NS_GADGET_DEFINITION",
"name": "Gadget_definition",
"protection": "gadgets-definition-edit",
"capitallinkoverride": false
"capitallinkoverride": false,
"defaultcontentmodel": "GadgetDefinition"
},
{
"id": 2303,
@ -33,6 +34,9 @@
"name": "Gadget_definition_talk"
}
],
"ContentHandlers": {
"GadgetDefinition": "GadgetDefinitionContentHandler"
},
"AvailableRights": [
"gadgets-edit",
"gadgets-definition-edit"
@ -63,7 +67,13 @@
"SpecialGadgets": "SpecialGadgets.php",
"SpecialGadgetUsage": "SpecialGadgetUsage.php",
"GadgetRepo": "includes/GadgetRepo.php",
"MediaWikiGadgetsDefinitionRepo": "includes/MediaWikiGadgetsDefinitionRepo.php"
"GadgetDefinitionNamespaceRepo": "includes/GadgetDefinitionNamespaceRepo.php",
"MediaWikiGadgetsDefinitionRepo": "includes/MediaWikiGadgetsDefinitionRepo.php",
"GadgetDefinitionContent": "includes/content/GadgetDefinitionContent.php",
"GadgetDefinitionContentHandler": "includes/content/GadgetDefinitionContentHandler.php",
"GadgetDefinitionValidator": "includes/content/GadgetDefinitionValidator.php",
"GadgetDefinitionSecondaryDataUpdate": "includes/content/GadgetDefinitionSecondaryDataUpdate.php",
"GadgetDefinitionDeletionUpdate": "includes/content/GadgetDefinitionDeletionUpdate.php"
},
"Hooks": {
"ArticleSaveComplete": [
@ -72,6 +82,18 @@
"BeforePageDisplay": [
"GadgetHooks::beforePageDisplay"
],
"CodeEditorGetPageLanguage": [
"GadgetHooks::onCodeEditorGetPageLanguage"
],
"ContentHandlerDefaultModelFor": [
"GadgetHooks::onContentHandlerDefaultModelFor"
],
"EditFilterMergedContent": [
"GadgetHooks::onEditFilterMergedContent"
],
"PageContentInsertComplete": [
"GadgetHooks::onPageContentInsertComplete"
],
"UserGetDefaultOptions": [
"GadgetHooks::userGetDefaultOptions"
],
@ -88,5 +110,8 @@
"GadgetHooks::onwgQueryPages"
]
},
"config": {
"GadgetsRepoClass": "MediaWikiGadgetsDefinitionRepo"
},
"manifest_version": 1
}

View file

@ -26,6 +26,8 @@
"gadgets-not-found": "Gadget \"$1\" not found.",
"gadgets-export-text": "To export the $1 gadget, click on \"{{int:gadgets-export-download}}\" button, save the downloaded file,\ngo to Special:Import on destination wiki and upload it. Then add the following to MediaWiki:Gadgets-definition page:\n<pre>$2</pre>\nYou must have appropriate permissions on destination wiki (including the right to edit system messages) and import from file uploads must be enabled.",
"gadgets-export-download": "Download",
"gadgets-validate-notset": "The property <code>$1</code> is not set.",
"gadgets-validate-wrongtype": "The property <code>$1</code> must be of type <code>$2</code> instead of <code>$3</code>.",
"apihelp-query+gadgetcategories-description": "Returns a list of gadget categories.",
"apihelp-query+gadgetcategories-param-prop": "What gadget category information to get:\n;name:Internal category name.\n;title:Category title.\n;members:Number of gadgets in category.",
"apihelp-query+gadgetcategories-param-names": "Names of categories to retrieve.",

View file

@ -38,6 +38,8 @@
"gadgets-not-found": "Used as error message. Parameters:\n* $1 - gadget name",
"gadgets-export-text": "Used as page description in [[Special:Gadgets]].\n\nRefers to {{msg-mw|Gadgets-export-download}}.\n\nSee example: [[Special:Gadgets/export/editbuttons]]\n\nFollowed by the \"Export\" form.\n\nParameters:\n* $1 - gadget name\n* $2 - gadget definition (code)",
"gadgets-export-download": "Use the verb for this message. Submit button.\n{{Identical|Download}}",
"gadgets-validate-notset": "Error message shown if a a required property is not set. $1 is the name of the property, e.g. settings.rights .",
"gadgets-validate-wrongtype": "Error message shown if a property is set to the wrong type. * $1 is the name of the property, e.g. settings.rights or module.messages[3].\n* $2 is the type that this property is expected to have\n* $3 is the type it actually had",
"apihelp-query+gadgetcategories-description": "{{doc-apihelp-description|query+gadgetcategories}}",
"apihelp-query+gadgetcategories-param-prop": "{{doc-apihelp-param|query+gadgetcategories|prop}}",
"apihelp-query+gadgetcategories-param-names": "{{doc-apihelp-param|query+gadgetcategories|names}}",

View file

@ -0,0 +1,126 @@
<?php
/**
* GadgetRepo implementation where each gadget has a page in
* the Gadget definition namespace, and scripts and styles are
* located in the Gadget namespace.
*/
class GadgetDefinitionNamespaceRepo extends GadgetRepo {
/**
* How long in seconds the list of gadget ids and
* individual gadgets should be cached for (1 day)
*/
const CACHE_TTL = 86400;
/**
* @var WANObjectCache
*/
private $wanCache;
/**
* @var string
*/
private $idsKey;
public function __construct () {
$this->idsKey = wfMemcKey( 'gadgets', 'namespace', 'ids' );
$this->wanCache = ObjectCache::getMainWANInstance();
}
/**
* Purge the list of gadget ids when a page is deleted
* or if a new page is created
*/
public function purgeGadgetIdsList() {
$this->wanCache->touchCheckKey( $this->idsKey );
}
/**
* Get a list of gadget ids from cache/database
*
* @return string[]
*/
public function getGadgetIds() {
return $this->wanCache->getWithSetCallback(
$this->idsKey,
self::CACHE_TTL,
function( $oldValue, &$ttl, array &$setOpts ) {
$dbr = wfGetDB( DB_SLAVE );
$setOpts += Database::getCacheSetOptions( $dbr );
return $dbr->selectFieldValues(
'page',
'page_title',
array(
'page_namespace' => NS_GADGET_DEFINITION
),
__METHOD__
);
},
array(
'checkKeys' => array( $this->idsKey ),
'pcTTL' => 5,
'lockTSE' => '30',
)
);
}
/**
* Update the cache for a specific Gadget whenever it is updated
*
* @param string $id
*/
public function updateGadgetObjectCache( $id ) {
$this->wanCache->touchCheckKey( $this->getGadgetCacheKey( $id ) );
}
private function getGadgetCacheKey( $id ) {
return wfMemcKey( 'gadgets', 'object', md5( $id ), Gadget::GADGET_CLASS_VERSION );
}
/**
* @param string $id
* @throws InvalidArgumentException
* @return Gadget
*/
public function getGadget( $id ) {
$key = $this->getGadgetCacheKey( $id );
$gadget = $this->wanCache->getWithSetCallback(
$key,
self::CACHE_TTL,
function( $old, &$ttl, array &$setOpts ) use ( $id ) {
$setOpts += Database::getCacheSetOptions( wfGetDB( DB_SLAVE ) );
$title = Title::makeTitleSafe( NS_GADGET_DEFINITION, $id );
if ( !$title ) {
$ttl = WANObjectCache::TTL_UNCACHEABLE;
return null;
}
$rev = Revision::newFromTitle( $title );
if ( !$rev ) {
$ttl = WANObjectCache::TTL_UNCACHEABLE;
return null;
}
$content = $rev->getContent();
if ( !$content instanceof GadgetDefinitionContent ) {
// Uhm...
$ttl = WANObjectCache::TTL_UNCACHEABLE;
return null;
}
return Gadget::newFromDefinitionContent( $id, $content );
},
array(
'checkKeys' => array( $key ),
'pcTTL' => 5,
'lockTSE' => '30',
)
);
if ( $gadget === null ) {
throw new InvalidArgumentException( "No gadget registered for '$id'" );
}
return $gadget;
}
}

View file

@ -10,6 +10,9 @@ abstract class GadgetRepo {
/**
* Get the ids of the gadgets provided by this repository
*
* It's possible this could be out of sync with what
* getGadget() will return due to caching
*
* @return string[]
*/
abstract public function getGadgetIds();
@ -31,7 +34,11 @@ abstract class GadgetRepo {
public function getStructuredList() {
$list = array();
foreach ( $this->getGadgetIds() as $id ) {
$gadget = $this->getGadget( $id );
try {
$gadget = $this->getGadget( $id );
} catch ( InvalidArgumentException $e ) {
continue;
}
$list[$gadget->getCategory()][$gadget->getName()] = $gadget;
}
@ -39,15 +46,14 @@ abstract class GadgetRepo {
}
/**
* Get the configured default GadgetRepo. Currently
* this hardcodes MediaWikiGadgetsDefinitionRepo since
* that is the only implementation
* Get the configured default GadgetRepo.
*
* @return GadgetRepo
*/
public static function singleton() {
if ( self::$instance === null ) {
self::$instance = new MediaWikiGadgetsDefinitionRepo();
global $wgGadgetsRepoClass; // @todo use Config here
self::$instance = new $wgGadgetsRepoClass();
}
return self::$instance;
}

View file

@ -1,40 +1,65 @@
<?php
/**
* Class representing a list of resources for one gadget
* Class representing a list of resources for one gadget, basically a wrapper
* around the Gadget class.
*/
class GadgetResourceLoaderModule extends ResourceLoaderWikiModule {
private $pages, $dependencies, $messages;
/**
* @var string
*/
private $id;
/**
* @var Gadget
*/
private $gadget;
/**
* Creates an instance of this class
*
* @param $pages Array: Associative array of pages in ResourceLoaderWikiModule-compatible
* format, for example:
* array(
* 'MediaWiki:Gadget-foo.js' => array( 'type' => 'script' ),
* 'MediaWiki:Gadget-foo.css' => array( 'type' => 'style' ),
* )
* @param $dependencies Array: Names of resources this module depends on
* @param $targets Array: List of targets this module support
* @param $position String: 'bottom' or 'top'
* @param $messages Array
* @param array $options
*/
public function __construct( $pages, $dependencies, $targets, $position, $messages ) {
$this->pages = $pages;
$this->dependencies = $dependencies;
$this->targets = $targets;
$this->position = $position;
$this->messages = $messages;
public function __construct( array $options ) {
$this->id = $options['id'];
}
/**
* Overrides the abstract function from ResourceLoaderWikiModule class
* @param $context ResourceLoaderContext
* @return Array: $pages passed to __construct()
* @return Gadget instance this module is about
*/
private function getGadget() {
if ( !$this->gadget ) {
try {
$this->gadget = GadgetRepo::singleton()->getGadget( $this->id );
} catch ( InvalidArgumentException $e ) {
// Fallback to a placeholder object...
$this->gadget = Gadget::newEmptyGadget( $this->id );
}
}
return $this->gadget;
}
/**
* Overrides the function from ResourceLoaderWikiModule class
* @param ResourceLoaderContext $context
* @return array
*/
protected function getPages( ResourceLoaderContext $context ) {
return $this->pages;
$gadget = $this->getGadget();
$pages = array();
foreach ( $gadget->getStyles() as $style ) {
$pages[$style] = array( 'type' => 'style' );
}
if ( $gadget->supportsResourceLoader() ) {
foreach ( $gadget->getScripts() as $script ) {
$pages[$script] = array( 'type' => 'script' );
}
}
return $pages;
}
/**
@ -43,7 +68,7 @@ class GadgetResourceLoaderModule extends ResourceLoaderWikiModule {
* @return Array: Names of resources this module depends on
*/
public function getDependencies( ResourceLoaderContext $context = null ) {
return $this->dependencies;
return $this->getGadget()->getDependencies();
}
/**
@ -51,10 +76,14 @@ class GadgetResourceLoaderModule extends ResourceLoaderWikiModule {
* @return String: 'bottom' or 'top'
*/
public function getPosition() {
return $this->position;
return $this->getGadget()->getPosition();
}
public function getMessages() {
return $this->messages;
return $this->getGadget()->getMessages();
}
public function getTargets() {
return $this->getGadget()->getTargets();
}
}

View file

@ -216,7 +216,7 @@ class MediaWikiGadgetsDefinitionRepo extends GadgetRepo {
}
foreach ( preg_split( '/\s*\|\s*/', $m[3], -1, PREG_SPLIT_NO_EMPTY ) as $page ) {
$page = "Gadget-$page";
$page = "MediaWiki:Gadget-$page";
if ( preg_match( '/\.js/', $page ) ) {
$info['scripts'][] = $page;

View file

@ -0,0 +1,125 @@
<?php
/**
* Copyright 2014
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
class GadgetDefinitionContent extends JsonContent {
public function __construct( $text ) {
parent::__construct( $text, 'GadgetDefinition' );
}
public function isValid() {
// parent::isValid() is called in validate()
return $this->validate()->isOK();
}
/**
* Pretty-print JSON.
*
* If called before validation, it may return JSON "null".
*
* @return string
*/
public function beautifyJSON() {
// @todo we should normalize entries in module.scripts and module.styles
return FormatJson::encode( $this->getAssocArray(), true, FormatJson::UTF8_OK );
}
/**
* Register some links
*
* @param Title $title
* @param int $revId
* @param ParserOptions $options
* @param bool $generateHtml
* @param ParserOutput $output
*/
protected function fillParserOutput( Title $title, $revId,
ParserOptions $options, $generateHtml, ParserOutput &$output
) {
parent::fillParserOutput( $title, $revId, $options, $generateHtml, $output );
$assoc = $this->getAssocArray();
foreach ( array( 'scripts', 'styles' ) as $type ) {
foreach ( $assoc['module'][$type] as $page ) {
$title = Title::makeTitleSafe( NS_GADGET, $page );
if ( $title ) {
$output->addLink( $title );
}
}
}
}
/**
* @return Status
*/
public function validate() {
if ( !parent::isValid() ) {
return $this->getData();
}
$validator = new GadgetDefinitionValidator();
return $validator->validate( $this->getAssocArray() );
}
/**
* Get the JSON content as an associative array with
* all fields filled out, populating defaults as necessary.
*
* @return array
*/
public function getAssocArray() {
$info = wfObjectToArray( $this->getData()->getValue() );
/** @var GadgetDefinitionContentHandler $handler */
$handler = $this->getContentHandler();
$info = wfArrayPlus2d( $info, $handler->getDefaultMetadata() );
return $info;
}
/**
* @param WikiPage $page
* @param ParserOutput $parserOutput
* @return DataUpdate[]
*/
public function getDeletionUpdates( WikiPage $page, ParserOutput $parserOutput = null ) {
return array_merge(
parent::getDeletionUpdates( $page, $parserOutput ),
array( new GadgetDefinitionDeletionUpdate( $page->getTitle()->getText() ) )
);
}
/**
* @param Title $title
* @param Content $old
* @param bool $recursive
* @param ParserOutput $parserOutput
* @return DataUpdate[]
*/
public function getSecondaryDataUpdates( Title $title, Content $old = null,
$recursive = true, ParserOutput $parserOutput = null
) {
return array_merge(
parent::getSecondaryDataUpdates( $title, $old, $recursive, $parserOutput ),
array( new GadgetDefinitionSecondaryDataUpdate( $title->getText() ) )
);
}
}

View file

@ -0,0 +1,64 @@
<?php
/**
* Copyright 2014
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
class GadgetDefinitionContentHandler extends JsonContentHandler {
public function __construct() {
parent::__construct( 'GadgetDefinition' );
}
/**
* @param Title $title
* @return bool
*/
public function canBeUsedOn( Title $title ) {
return $title->inNamespace( NS_GADGET_DEFINITION );
}
protected function getContentClass() {
return 'GadgetDefinitionContent';
}
public function makeEmptyContent() {
$class = $this->getContentClass();
return new $class( FormatJson::encode( $this->getDefaultMetadata(), true ) );
}
public function getDefaultMetadata() {
return array(
'settings' => array(
'rights' => array(),
'default' => false,
'hidden' => false,
'skins' => array(),
'category' => ''
),
'module' => array(
'scripts' => array(),
'styles' => array(),
'dependencies' => array(),
'messages' => array(),
'position' => 'bottom',
),
);
}
}

View file

@ -0,0 +1,45 @@
<?php
/**
* Copyright 2014
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
/**
* DataUpdate to run whenever a page in the Gadget definition
* is deleted.
*/
class GadgetDefinitionDeletionUpdate extends DataUpdate {
/**
* Gadget id
* @var string
*/
private $id;
public function __construct( $id ) {
$this->id = $id;
}
public function doUpdate() {
$repo = GadgetRepo::singleton();
if ( $repo instanceof GadgetDefinitionNamespaceRepo ) {
$repo->purgeGadgetIdsList();
$repo->updateGadgetObjectCache( $this->id );
}
}
}

View file

@ -0,0 +1,37 @@
<?php
/**
* Copyright 2014
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*/
class GadgetDefinitionSecondaryDataUpdate extends DataUpdate {
private $id;
public function __construct( $id ) {
$this->id = $id;
}
public function doUpdate() {
$repo = GadgetRepo::singleton();
if ( $repo instanceof GadgetDefinitionNamespaceRepo ) {
$repo->updateGadgetObjectCache( $this->id );
}
}
}

View file

@ -0,0 +1,89 @@
<?php
/**
* Class responsible for validating Gadget definition contents
*
* @todo maybe this should use a formal JSON schema validator or something
*/
class GadgetDefinitionValidator {
/**
* Validation metadata.
* 'foo.bar.baz' => array( 'type check callback', 'type name' [, 'member type check callback', 'member type name'] )
*/
protected static $propertyValidation = array(
'settings' => array( 'is_array', 'array' ),
'settings.rights' => array( 'is_array', 'array' , 'is_string', 'string' ),
'settings.default' => array( 'is_bool', 'boolean' ),
'settings.hidden' => array( 'is_bool', 'boolean' ),
'settings.skins' => array( array( __CLASS__, 'isArrayOrTrue' ), 'array or true', 'is_string', 'string' ),
'settings.category' => array( 'is_string', 'string' ),
'module' => array( 'is_array', 'array' ),
'module.scripts' => array( 'is_array', 'array', 'is_string', 'string' ),
'module.styles' => array( 'is_array', 'array', 'is_string', 'string' ),
'module.dependencies' => array( 'is_array', 'array', 'is_string', 'string' ),
'module.messages' => array( 'is_array', 'array', 'is_string', 'string' ),
'module.position' => array( 'is_string', 'string' ),
);
/**
* @param mixed $value
* @return bool
*/
public static function isArrayOrTrue( $value ) {
return is_array( $value ) || $value === true;
}
/**
* Check the validity of the given properties array
* @param array $properties Return value of FormatJson::decode( $blob, true )
* @param bool $tolerateMissing If true, don't complain about missing keys
* @return Status object with error message if applicable
*/
public function validate( array $properties, $tolerateMissing = false ) {
foreach ( self::$propertyValidation as $property => $validation ) {
$path = explode( '.', $property );
$val = $properties;
// Walk down and verify that the path from the root to this property exists
foreach ( $path as $p ) {
if ( !array_key_exists( $p, $val ) ) {
if ( $tolerateMissing ) {
// Skip validation of this property altogether
continue 2;
} else {
return Status::newFatal( 'gadgets-validate-notset', $property );
}
}
$val = $val[$p];
}
// Do the actual validation of this property
$func = $validation[0];
if ( !call_user_func( $func, $val ) ) {
return Status::newFatal(
'gadgets-validate-wrongtype',
$property,
$validation[1],
gettype( $val )
);
}
if ( isset( $validation[2] ) && is_array( $val ) ) {
// Descend into the array and check the type of each element
$func = $validation[2];
foreach ( $val as $i => $v ) {
if ( !call_user_func( $func, $v ) ) {
return Status::newFatal(
'gadgets-validate-wrongtype',
"{$property}[{$i}]",
$validation[3],
gettype( $v )
);
}
}
}
}
return Status::newGood();
}
}

View file

@ -0,0 +1,74 @@
{
"$schema": "http://json-schema.org/schema#",
"description": "Gadget definition schema",
"type": "object",
"additionalProperties": false,
"properties": {
"settings": {
"type": "object",
"additionalProperties": false,
"properties": {
"rights": {
"description": "The rights required to be able to enable/load this gadget",
"type": "array",
"items": {
"type": "string"
}
},
"default": {
"description": "Whether this gadget is enabled by default",
"type": "boolean",
"default": false
},
"hidden": {
"description": "Whether this gadget is hidden from preferences",
"type": "boolean",
"default": false
},
"skins": {
"description": "Skins supported by this gadget; empty or true if all skins are supported",
"type": [ "array", "boolean" ],
"items": {
"type": "string"
}
},
"category": {
"description": "Key of the category this gadget belongs to",
"type": "string",
"default": ""
}
}
},
"module": {
"type": "object",
"additionalProperties": false,
"properties": {
"scripts": {
"type": "array",
"description": "List of JavaScript pages included in this gadget"
},
"styles": {
"type": "array",
"description": "List of CSS pages included in this gadget"
},
"dependencies": {
"type": "array",
"description": "ResourceLoader modules this gadget depends upon"
},
"messages": {
"type": "array",
"description": "Messages this gadget depends upon"
},
"position": {
"type": "string",
"description": "Whether this module should be loaded asynchronously after the page loads (bottom) or synchronously before the page is rendered (top)",
"enum": [
"top",
"bottom"
],
"default": "bottom"
}
}
}
}
}

View file

@ -25,12 +25,12 @@ class GadgetsTest extends MediaWikiTestCase {
public function testSimpleCases() {
$g = $this->create( '* foo bar| foo.css|foo.js|foo.bar' );
$this->assertEquals( 'foo_bar', $g->getName() );
$this->assertEquals( 'ext.gadget.foo_bar', $g->getModuleName() );
$this->assertEquals( array( 'Gadget-foo.js' ), $g->getScripts() );
$this->assertEquals( array( 'Gadget-foo.css' ), $g->getStyles() );
$this->assertEquals( array( 'Gadget-foo.js', 'Gadget-foo.css' ),
$this->assertEquals( 'ext.gadget.foo_bar', Gadget::getModuleName( $g->getName() ) );
$this->assertEquals( array( 'MediaWiki:Gadget-foo.js' ), $g->getScripts() );
$this->assertEquals( array( 'MediaWiki:Gadget-foo.css' ), $g->getStyles() );
$this->assertEquals( array( 'MediaWiki:Gadget-foo.js', 'MediaWiki:Gadget-foo.css' ),
$g->getScriptsAndStyles() );
$this->assertEquals( array( 'Gadget-foo.js' ), $g->getLegacyScripts() );
$this->assertEquals( array( 'MediaWiki:Gadget-foo.js' ), $g->getLegacyScripts() );
$this->assertFalse( $g->supportsResourceLoader() );
$this->assertTrue( $g->hasModule() );
}
@ -44,7 +44,7 @@ class GadgetsTest extends MediaWikiTestCase {
public function testDependencies() {
$g = $this->create( '* foo[ResourceLoader|dependencies=jquery.ui]|bar.js' );
$this->assertEquals( array( 'Gadget-bar.js' ), $g->getScripts() );
$this->assertEquals( array( 'MediaWiki:Gadget-bar.js' ), $g->getScripts() );
$this->assertTrue( $g->supportsResourceLoader() );
$this->assertEquals( array( 'jquery.ui' ), $g->getDependencies() );
}