Kaldari has submitted this change and it was merged.

Change subject: Implement Gadgets definition namespace repo
......................................................................


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
---
M GadgetHooks.php
M Gadgets_body.php
M README
M SpecialGadgets.php
M extension.json
M i18n/en.json
M i18n/qqq.json
A includes/GadgetDefinitionNamespaceRepo.php
M includes/GadgetRepo.php
M includes/GadgetResourceLoaderModule.php
M includes/MediaWikiGadgetsDefinitionRepo.php
A includes/content/GadgetDefinitionContent.php
A includes/content/GadgetDefinitionContentHandler.php
A includes/content/GadgetDefinitionDeletionUpdate.php
A includes/content/GadgetDefinitionSecondaryDataUpdate.php
A includes/content/GadgetDefinitionValidator.php
A includes/content/schema.json
M tests/GadgetTest.php
18 files changed, 841 insertions(+), 99 deletions(-)

Approvals:
  Kaldari: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/GadgetHooks.php b/GadgetHooks.php
index 44f158b..2446c67 100644
--- a/GadgetHooks.php
+++ b/GadgetHooks.php
@@ -144,16 +144,12 @@
        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 @@
                 */
                $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 @@
                );
        }
 
+
+       /**
+        * 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
diff --git a/Gadgets_body.php b/Gadgets_body.php
index da224da..9e75abd 100644
--- a/Gadgets_body.php
+++ b/Gadgets_body.php
@@ -64,13 +64,54 @@
        }
 
        /**
+        * 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
         *
         * @param string $id
         * @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 @@
        }
 
        /**
-        * @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 @@
         */
        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 @@
        }
 
        /**
-        * @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 @@
        }
 
        /**
-        * 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;
        }
 
        /**
diff --git a/README b/README
index b7bea4f..ac85d70 100644
--- a/README
+++ b/README
@@ -35,3 +35,9 @@
 * 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.
diff --git a/SpecialGadgets.php b/SpecialGadgets.php
index 095de46..1428e8f 100644
--- a/SpecialGadgets.php
+++ b/SpecialGadgets.php
@@ -44,6 +44,7 @@
                        return;
                }
 
+               $output->disallowUserJs();
                $lang = $this->getLanguage();
                $langSuffix = "";
                if ( $lang->getCode() != $wgContLang->getCode() ) {
@@ -140,21 +141,25 @@
                                        );
                                }
 
-                               $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 @@
 
                $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 ) )
diff --git a/extension.json b/extension.json
index 563443e..92576c3 100644
--- a/extension.json
+++ b/extension.json
@@ -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": [
@@ -71,6 +81,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
 }
diff --git a/i18n/en.json b/i18n/en.json
index 4d2a164..ba2e83f 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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.",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 7378bc6..d018e4a 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -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}}",
diff --git a/includes/GadgetDefinitionNamespaceRepo.php 
b/includes/GadgetDefinitionNamespaceRepo.php
new file mode 100644
index 0000000..3d0a423
--- /dev/null
+++ b/includes/GadgetDefinitionNamespaceRepo.php
@@ -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;
+       }
+}
diff --git a/includes/GadgetRepo.php b/includes/GadgetRepo.php
index 18bf5b5..f4e9f18 100644
--- a/includes/GadgetRepo.php
+++ b/includes/GadgetRepo.php
@@ -10,6 +10,9 @@
        /**
         * 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 @@
        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 @@
        }
 
        /**
-        * 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;
        }
diff --git a/includes/GadgetResourceLoaderModule.php 
b/includes/GadgetResourceLoaderModule.php
index d4b537d..14156b7 100644
--- a/includes/GadgetResourceLoaderModule.php
+++ b/includes/GadgetResourceLoaderModule.php
@@ -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 @@
         * @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 @@
         * @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();
        }
 }
diff --git a/includes/MediaWikiGadgetsDefinitionRepo.php 
b/includes/MediaWikiGadgetsDefinitionRepo.php
index 5377fad..45277d9 100644
--- a/includes/MediaWikiGadgetsDefinitionRepo.php
+++ b/includes/MediaWikiGadgetsDefinitionRepo.php
@@ -216,7 +216,7 @@
                }
 
                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;
diff --git a/includes/content/GadgetDefinitionContent.php 
b/includes/content/GadgetDefinitionContent.php
new file mode 100644
index 0000000..c72d708
--- /dev/null
+++ b/includes/content/GadgetDefinitionContent.php
@@ -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() ) )
+               );
+       }
+}
diff --git a/includes/content/GadgetDefinitionContentHandler.php 
b/includes/content/GadgetDefinitionContentHandler.php
new file mode 100644
index 0000000..49d4f18
--- /dev/null
+++ b/includes/content/GadgetDefinitionContentHandler.php
@@ -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',
+                       ),
+               );
+
+       }
+}
diff --git a/includes/content/GadgetDefinitionDeletionUpdate.php 
b/includes/content/GadgetDefinitionDeletionUpdate.php
new file mode 100644
index 0000000..26951bc
--- /dev/null
+++ b/includes/content/GadgetDefinitionDeletionUpdate.php
@@ -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 );
+               }
+       }
+}
diff --git a/includes/content/GadgetDefinitionSecondaryDataUpdate.php 
b/includes/content/GadgetDefinitionSecondaryDataUpdate.php
new file mode 100644
index 0000000..cb1d802
--- /dev/null
+++ b/includes/content/GadgetDefinitionSecondaryDataUpdate.php
@@ -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 );
+               }
+       }
+}
diff --git a/includes/content/GadgetDefinitionValidator.php 
b/includes/content/GadgetDefinitionValidator.php
new file mode 100644
index 0000000..e17c166
--- /dev/null
+++ b/includes/content/GadgetDefinitionValidator.php
@@ -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();
+       }
+}
diff --git a/includes/content/schema.json b/includes/content/schema.json
new file mode 100644
index 0000000..5d4a10d
--- /dev/null
+++ b/includes/content/schema.json
@@ -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"
+                               }
+                       }
+               }
+       }
+}
diff --git a/tests/GadgetTest.php b/tests/GadgetTest.php
index 26d77e5..86ac0fd 100644
--- a/tests/GadgetTest.php
+++ b/tests/GadgetTest.php
@@ -25,12 +25,12 @@
        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 @@
 
        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() );
        }

-- 
To view, visit https://gerrit.wikimedia.org/r/228781
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ib11db5fb0f7b46793bfa956cf1367f1dc1059b1c
Gerrit-PatchSet: 18
Gerrit-Project: mediawiki/extensions/Gadgets
Gerrit-Branch: master
Gerrit-Owner: Legoktm <[email protected]>
Gerrit-Reviewer: Aaron Schulz <[email protected]>
Gerrit-Reviewer: Alex Monk <[email protected]>
Gerrit-Reviewer: Kaldari <[email protected]>
Gerrit-Reviewer: Legoktm <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to