Legoktm has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/247869

Change subject: [WIP] Add Gadget manager, overhaul Special:Gadgets
......................................................................

[WIP] Add Gadget manager, overhaul Special:Gadgets

Change-Id: Ibaf43dd5d147277b61702a34e7332437c3395c17
---
A SpecialGadgets2.php
A api/ApiQueryGadgetPages.php
M extension.json
A modules/ext.gadgets.api.js
A modules/ext.gadgets.gadgetmanager.css
A modules/ext.gadgets.gadgetmanager.js
A modules/ext.gadgets.init.js
A modules/ext.gadgets.preferences.css
A modules/ext.gadgets.preferences.js
A modules/ext.gadgets.specialgadgets.prejs.css
A modules/ext.gadgets.specialgadgets.tabs.js
A modules/images/close.png
A modules/images/edit-faded.png
A modules/images/edit.png
A modules/images/input-error.png
A modules/images/input-loading.gif
A modules/images/input-ok.png
A modules/jquery.createPropCloud.js
18 files changed, 2,267 insertions(+), 0 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Gadgets 
refs/changes/69/247869/1

diff --git a/SpecialGadgets2.php b/SpecialGadgets2.php
new file mode 100644
index 0000000..68cdd3c
--- /dev/null
+++ b/SpecialGadgets2.php
@@ -0,0 +1,515 @@
+<?php
+/**
+ * SpecialPage for Gadgets.
+ *
+ * @file
+ * @ingroup Extensions
+ */
+
+class SpecialGadgets extends SpecialPage {
+       /**
+        * @var $params Array: Parameters passed to the page.
+        * - gadget String|null: Gadget id
+        * - action String: Action ('view', 'export')
+        */
+       protected $params = array(
+               'gadget' => null,
+               'action' => 'view',
+       );
+
+       private $par;
+
+       /**
+        * @var GadgetRepo
+        */
+       private $repo;
+
+       public function __construct() {
+               parent::__construct( 'Gadgets' );
+       }
+
+       /**
+        * Main execution function.
+        * @todo: Add canonical links to <head> to avoid indexing of link 
variations and stuff like
+        * [[Special:Gadgets/id/export/bablabla]]. Those should either redirect 
and/or have a canonical
+        * link in the <head> ($out->addLink).
+        * @param $par String: Parameters passed to the page.
+        */
+       public function execute( $par ) {
+               $this->par = $par;
+               $this->repo = GadgetRepo::singleton();
+               $out = $this->getOutput();
+               $out->disallowUserJs();
+               $out->addModuleStyles( 'ext.gadgets.specialgadgets.prejs' );
+               $out->addModules( 'ext.gadgets.specialgadgets.tabs' );
+
+               // Map title parts to query string
+               if ( is_string( $par ) ) {
+                       $parts = explode( '/', $par, 3 );
+                       $this->params['gadget'] = $parts[0];
+                       if ( isset( $parts[1] ) ) {
+                               $this->params['action'] = $parts[1];
+                       }
+               }
+
+               // Parameters (overrides title parts)
+               $this->params['gadget'] = $this->getRequest()->getVal( 
'gadget', $this->params['gadget'] );
+               $this->params['action'] = $this->getRequest()->getVal( 
'action', $this->params['action'] );
+
+               // Get instance of Gadget
+               $gadget = false;
+               if ( !is_null( $this->params['gadget'] ) ) {
+                       try {
+                               $gadget = $this->repo->getGadget( 
$this->params['gadget'] );
+                       } catch ( InvalidArgumentException $e ) {
+                               $out->showErrorPage( 'error', 
'gadgets-not-found', array( $this->params['gadget'] ) );
+                               return;
+                       }
+               }
+
+               // Handle the the query
+               switch( $this->params['action'] ) {
+                       case 'view':
+                               if ( $gadget ) {
+                                       $this->showSingleGadget( $gadget );
+                               } else {
+                                       $this->showAllGadgets();
+                               }
+                               break;
+                       case 'export':
+                               if ( $gadget ) {
+                                       $this->showExportForm( $gadget );
+                               } else {
+                                       $out->showErrorPage( 'error', 
'gadgets-nosuchaction' );
+                               }
+                               break;
+                       default:
+                               $out->showErrorPage( 'error', 
'gadgets-nosuchaction' );
+                               break;
+               }
+       }
+
+       /**
+        * Returns one <div class="mw-gadgets-gadget">..</div>
+        * for the given Gadget object.
+        *
+        * @param Gadget $gadget
+        * @return string HTML
+        */
+       protected function getGadgetHtml( Gadget $gadget ) {
+               global $wgContLang;
+               $user = $this->getUser();
+               $userlang = $this->getLanguage();
+
+               // Suffix needed after page names in links to NS_MEDIAWIKI,
+               // e.g. to link to [[MediaWiki:Foo/nl]] instead of 
[[MediaWiki:Foo]]
+               $suffix = '';
+               if ( $userlang->getCode() !== $wgContLang->getCode() ) {
+                       $suffix = '/' . $userlang->getCode();
+               }
+
+               $html = Html::openElement( 'div', array(
+                       'class' => 'mw-gadgets-gadget',
+                       'data-gadget-id' => $gadget->getName(),
+               ) );
+
+               // Gadgetlinks section in the Gadget title heading
+               $extra = array();
+
+               $extra[] = Linker::link(
+                       $this->getPageTitle( $gadget->getName() ),
+                       $this->msg( 'gadgets-gadget-permalink' )->escaped(),
+                       array(
+                               'title' => $this->msg( 
'gadgets-gadget-permalink-tooltip', $gadget->getName() )->plain(),
+                               'class' => 'mw-gadgets-permalink',
+                       )
+               );
+
+               $extra[] = Linker::link(
+                       $this->getPageTitle( "{$gadget->getName()}/export" ),
+                       $this->msg( 'gadgets-gadget-export' )->escaped(),
+                       array(
+                               'title' => $this->msg( 
'gadgets-gadget-export-tooltip', $gadget->getName() )->plain(),
+                               'class' => 'mw-gadgets-export',
+                       )
+
+               );
+               $gadgetDefinitionTitle = $this->repo->getDefinitionTitle( 
$gadget->getName() );
+
+               if ( $gadgetDefinitionTitle instanceof Title ) {
+                       $rights = $this->repo->getPermissionsForAction();
+                       if ( $user->isAllowed( $rights['edit'] ) ) {
+                               $extra[] = Linker::link(
+                                       $gadgetDefinitionTitle,
+                                       $this->msg( 'gadgets-gadget-modify' 
)->escaped(),
+                                       array(
+                                               'title' => $this->msg( 
'gadgets-gadget-modify-tooltip', $gadget->getId() )->plain(),
+                                               'class' => 'mw-gadgets-modify',
+                                       ),
+                                       array( 'action' => 'edit' )
+                               );
+                       }
+
+                       // Adding a delete link only applies to Gadget 
definition namespace
+                       if ( $this->repo instanceof 
GadgetDefinitionNamespaceRepo
+                                       && $user->isAllowed( $rights['delete'] )
+                       ) {
+                               $extra[] = Linker::link(
+                                       $gadgetDefinitionTitle,
+                                       $this->msg( 'gadgets-gadget-delete' 
)->escaped(),
+                                       array(
+                                               'title' => $this->msg( 
'gadgets-gadget-delete-tooltip', $gadget->getId() )->plain(),
+                                               'class' => 'mw-gadgets-delete',
+                                       ),
+                                       array( 'action' => 'delete' )
+                               );
+                       }
+               }
+
+               // Edit interface (gadget title and description)
+               $editTitle = $editDescription = '';
+               if ( $user->isAllowed( 'editinterface' ) ) {
+                       $titleTitle = Title::makeTitleSafe( NS_MEDIAWIKI, 
$gadget->getTitleMessageKey() . $suffix );
+                       if ( $titleTitle ) {
+                               $editLink = Linker::link(
+                                       $titleTitle,
+                                       $this->msg( 'gadgets-message-edit' 
)->escaped(),
+                                       array( 'title' => $this->msg( 
'gadgets-message-edit-tooltip', $titleTitle->getPrefixedText() )->plain() ),
+                                       array( 'action' => 'edit' )
+                               );
+                               $editTitle = '<span 
class="mw-gadgets-messagelink">' . $editLink . '</span>';
+                       }
+
+                       $descriptionTitle = Title::makeTitleSafe( NS_MEDIAWIKI, 
$gadget->getDescriptionMessageKey() . $suffix );
+                       if ( $descriptionTitle ) {
+                               $editLink = Linker::link(
+                                               $descriptionTitle,
+                                       $this->msg( 
$descriptionTitle->isKnown() ? 'gadgets-desc-edit' : 'gadgets-desc-add' 
)->escaped(),
+                                       array( 'title' => $this->msg( 
$descriptionTitle->isKnown() ? 'gadgets-desc-edit-tooltip' : 
'gadgets-desc-add-tooltip', $descriptionTitle->getPrefixedText() )->plain() ),
+                                       array( 'action' => 'edit' )
+                               );
+                               $editDescription = '<span 
class="mw-gadgets-messagelink">' . $editLink . '</span>';
+                       }
+               }
+
+               // Gadget heading
+               $html .= '<div class="mw-gadgets-title">'
+                       . htmlspecialchars( $gadget->getTitleMessage() )
+                       . ' &#160; ' . $editTitle
+                       . Html::rawElement( 'span', array(
+                                       'class' => 'mw-gadgets-gadgetlinks',
+                                       'data-gadget-id' => $gadget->getId()
+                               ), implode( '', $extra )
+                       )
+                       . '</div>';
+
+               // Description
+               $html .= Html::rawElement( 'p', array(
+                       'class' => 'mw-gadgets-description'
+               ), $gadget->getDescriptionMessage() . '&#160;' . 
$editDescription );
+
+               $html .= '</div>';
+               return $html;
+       }
+
+       /**
+        * Handles [[Special:Gadgets]].
+        * Displays form showing the list of installed gadgets.
+        */
+       public function showAllGadgets() {
+               global $wgContLang;
+               $out = $this->getOutput();
+               $user = $this->getUser();
+               $userlang = $this->getLanguage();
+
+               $this->setHeaders();
+               $out->setPagetitle( $this->msg( 'gadgets-title' ) );
+
+               $gadgetsByCategory = $this->repo->getStructuredList();
+
+               // Only load the gadget manager module if needed
+               if ( $user->isAllowed( 'gadgets-definition-delete' )
+                       || $user->isAllowed( 'gadgets-definition-edit' )
+                       || $user->isAllowed( 'gadgets-definition-create' )
+               ) {
+                       $out->addModules( 'ext.gadgets.gadgetmanager' );
+               }
+
+               // If there there are no gadgets at all, exit early.
+               if ( !count( $gadgetsByCategory ) ) {
+                       $noGadgetsMsgHtml = Html::element( 'p',
+                               array(
+                                       'class' => 'mw-gadgets-nogadgets'
+                               ), $this->msg( 'gadgets-nogadgets' )->plain()
+                       );
+                       $this->getOutput()->addHtml( $noGadgetsMsgHtml );
+                       return;
+               }
+
+               // There is atleast one gadget, let's get started.
+               // FIXME: MediaWiki definition repo?
+               $out->addWikiMsg( 'gadgets-pagetext',
+                       SpecialPage::getTitleFor( 'Recentchanges', 'namespace=' 
. NS_GADGET_DEFINITION )->getPrefixedText()
+               );
+
+               // Sort categories alphabetically
+               ksort( $gadgetsByCategory );
+
+               // ksort causes key "''" to be sorted on top, we want it to be 
at the bottom,
+               // removing and re-adding the value.
+               if ( isset( $gadgetsByCategory[''] ) ) {
+                       $uncat = $gadgetsByCategory[''];
+                       unset( $gadgetsByCategory[''] );
+                       $gadgetsByCategory[''] = $uncat;
+               }
+
+               // Suffix needed after page names in links to NS_MEDIAWIKI,
+               // e.g. to link to [[MediaWiki:Foo/nl]] instead of 
[[MediaWiki:Foo]]
+               $suffix = '';
+               if ( $userlang->getCode() !== $wgContLang->getCode() ) {
+                       $suffix = '/' . $userlang->getCode();
+               }
+
+
+               $html = '';
+
+               foreach ( $gadgetsByCategory as $category => $gadgets ) {
+
+                       // Avoid broken or empty headings. Fallback to a 
special message
+                       // for uncategorized gadgets (e.g. gadgets with 
category '' ).
+                       if ( $category !== '' ) {
+                               // FIXME:
+                               // $categoryTitle = $repo->getCategoryTitle( 
$category );
+                               $categoryTitle = $category;
+                       } else {
+                               $categoryTitle = $this->msg( 
'gadgets-uncategorized' )->plain();
+                       }
+
+                       $editLink = '';
+                       if ( $user->isAllowed( 'editinterface' ) && $category 
!== '' ) {
+                               $t = Title::makeTitleSafe( NS_MEDIAWIKI, 
"gadgetcategory-{$category}{$suffix}" );
+                               if ( $t ) {
+                                       $editLink = Linker::link(
+                                               $t,
+                                               $this->msg( 
'gadgets-message-edit' )->escaped(),
+                                               array( 'title' => $this->msg( 
'gadgets-message-edit-tooltip', $t->getPrefixedText() ) ),
+                                               array( 'action' => 'edit' )
+                                       );
+                                       $editLink = '<span 
class="mw-gadgets-messagelink">' . $editLink . '</span>';
+                               }
+                       }
+
+                       // Category heading
+                       $html .= Html::rawElement( 'h2', array(), 
htmlspecialchars( $categoryTitle ) . ' &#160; ' . $editLink );
+
+                       // Start gadgets list
+                       $html .= '<div class="mw-gadgets-list">';
+
+                       foreach( $gadgets as $gadgetId => $gadget ) {
+                               $html .= $this->getGadgetHtml( $gadget );
+
+                       }
+
+                       $html .= '</div>';
+               }
+
+               $out->addHtml( $html );
+       }
+
+       /**
+        * Handles [[Special:Gadgets/id/export]].
+        * Exports a gadget with its dependencies in a serialized form.
+        * Should not be called if the gadget does not exist. $gadget must be
+        * an instance of Gadget, not null.
+        * @param $gadget Gadget: Gadget object of gadget to export.
+        */
+       public function showExportForm( $gadget ) {
+               $this->doSubpageMode();
+               $out = $this->getOutput();
+
+               /**
+                * @todo: Add note somewhere with link to mw.org help pages 
about gadget repos
+                * if this is a shared gadget and the user owns the wiki, he is 
recommended
+                * to instead pull from this repo natively.
+                */
+
+               $rights = array(
+                       'gadgets-definition-create',
+                       'gadgets-definition-edit',
+                       'gadgets-edit',
+                       'importupload',
+               );
+               $msg = array();
+               foreach( $rights as $right ) {
+                       $msg[] = Html::element( 'code', array(
+                                       'style' => 'white-space:nowrap',
+                                       'title' => $this->msg( "right-{$right}" 
)->text()
+                               ), $right
+                       );
+               }
+
+               $this->setHeaders();
+               $out->setPagetitle( $this->msg( 'gadgets-export-title', 
$gadget->getTitleMessage() ) );
+
+               // Make a list of all pagenames to be exported:
+               $exportTitles = array();
+
+               // NS_GADGET_DEFINITION page of this gadget
+               $exportTitles[] = GadgetsHooks::getDefinitionTitleFromID( 
$gadget->getId() );
+
+               // Title message in NS_MEDIAWIKI
+               $titleMessage = Title::makeTitleSafe( NS_MEDIAWIKI, 
$gadget->getTitleMessageKey() );
+               // Description message in NS_MEDIAWIKI
+               $descriptionMessage = Title::makeTitleSafe( NS_MEDIAWIKI, 
$gadget->getDescriptionMessageKey() );
+               // Add these pages and their subpages (for translations)
+               $exportTitles = array_merge( $exportTitles,
+                       array( $titleMessage ),
+                       iterator_to_array( $titleMessage->getSubpages() ),
+                       array( $descriptionMessage ),
+                       iterator_to_array( $descriptionMessage->getSubpages() )
+               );
+
+               // Module script and styles in NS_GADGET
+               foreach ( $gadget->getScripts() as $script ) {
+                       $exportTitles[] = Title::newFromText( $script );
+               }
+
+               foreach ( $gadget->getStyles() as $style ) {
+                       $exportTitles[] = Title::newFromText( $style );
+               }
+
+               // Module messages in NS_MEDIAWIKI
+               foreach( $gadget->getMessages() as $message ) {
+                       $message = Title::makeTitleSafe( NS_MEDIAWIKI, $message 
);
+                       // Add this page and its subpages (for translations)
+                       $exportTitles = array_merge( $exportTitles,
+                               array( $message ),
+                               iterator_to_array( $message->getSubpages() )
+                       );
+               }
+
+               // Translation subpages of module messages
+               // @todo
+
+               // Build line-break separated string of prefixed titles
+               $exportList = '';
+               // Build html for unordered list with links to the titles
+               $exportDisplayList = '<ul>';
+               /** @var Title $exportTitle */
+               foreach ( $exportTitles as $exportTitle ) {
+                       // Make sure it's not null (for inexisting or invalid 
title)
+                       // and addionally check exists() to avoid exporting 
messages
+                       // from NS_MEDIAWIKI that don't exist but are 
'isAlwaysKnown'
+                       // due to their default value from PHP messages files
+                       // (which we don't want to export)
+                       if ( $exportTitle && $exportTitle->exists() ) {
+                               $exportList .= $exportTitle->getPrefixedDBkey() 
. "\n";
+                               $exportDisplayList .= '<li>'. Linker::link( 
$exportTitle ) . '</li>';
+                       }
+               }
+               $exportDisplayList .= '</ul>';
+
+               $form =
+                       Html::openElement( 'form', array(
+                               'method' => 'get',
+                               'action' => wfScript(),
+                               'class' => 'mw-gadgets-exportform'
+                       ) )
+                       . '<fieldset><p>'
+                       . $this->msg( 'gadgets-export-text' )
+                               ->rawParams(
+                                       htmlspecialchars( $gadget->getId() ),
+                                       '', // $2 is no longer used. To avoid 
breaking backwards compatibility, skipped here and
+                                       // $3 is used for the new message part
+                                       $this->getLanguage()->listToText( $msg )
+                               )
+                               ->escaped()
+                       . '</p>'
+                       . $exportDisplayList
+                       . Html::hidden( 'title', SpecialPage::getTitleFor( 
'Export' )->getPrefixedDBKey() )
+                       . Html::hidden( 'pages', $exportList )
+                       . Html::hidden( 'wpDownload', '1' )
+                       . Html::hidden( 'templates', '1' )
+                       . Xml::submitButton( $this->msg( 
'gadgets-export-download' )->text() )
+                       . '</fieldset></form>';
+
+               $out->addHTML( $form );
+       }
+
+       /**
+        * Handles [[Special:Gadgets/id]].
+        * Should not be called if the gadget does not exist. $gadget must be
+        * an instance of Gadget, not null.
+        * @param $gadget Gadget
+        */
+       public function showSingleGadget( Gadget $gadget ) {
+               $this->doSubpageMode();
+               $out = $this->getOutput();
+               $user = $this->getUser();
+
+               $this->setHeaders();
+               $out->setPagetitle( $this->msg( 'gadgets-gadget-title', 
$gadget->getTitleMessage() ) );
+
+               // Only load the gadget manager module if needed
+               if ( $user->isAllowed( 'gadgets-definition-delete' )
+                       || $user->isAllowed( 'gadgets-definition-edit' )
+                       || $user->isAllowed( 'gadgets-definition-create' )
+               ) {
+                       $out->addModules( 'ext.gadgets.gadgetmanager' );
+               }
+
+               $out->addHTML( '<div class="mw-gadgets-list">' . 
$this->getGadgetHtml( $gadget ) . '</div>' );
+       }
+
+
+       /**
+        * Call this method internally to include a breadcrumb navigation on 
top of the page.
+        * Cannot be undone, should only be called once.
+        * @return Boolean: True if added, false if not added because already 
added.
+        */
+       public function doSubpageMode() {
+               static $done = false;
+               if ( $done ) {
+                       return false;
+               }
+               $done = true;
+
+               // Would be nice if we wouldn't have to duplicate
+               // this from Skin::subPageSubtitle. Slightly modified though
+               $subpages = '';
+               $ptext = $this->getPageTitle( $this->par )->getPrefixedText();
+               if ( preg_match( '/\//', $ptext ) ) {
+                       $links = explode( '/', $ptext );
+                       array_pop( $links );
+                       $growinglink = '';
+                       $display = '';
+                       $c = 0;
+
+                       foreach ( $links as $link ) {
+                               $growinglink .= $link;
+                               $display .= $link;
+                               $linkObj = Title::newFromText( $growinglink );
+
+                               if ( is_object( $linkObj ) ) {
+                                       $getlink = Linker::link( $linkObj, 
htmlspecialchars( $display ) );
+
+                                       $c++;
+                                       if ( $c > 1 ) {
+                                               $subpages .= $this->msg( 
'pipe-separator' )->escaped();
+                                       } else {
+                                               // First iteration
+                                               $subpages .= '&lt; ';
+                                       }
+
+                                       $subpages .= $getlink;
+                                       $display = '';
+                               } else {
+                                       $display .= '/';
+                               }
+                               $growinglink .= '/';
+                       }
+               }
+               $this->getOutput()->setSubtitle( $subpages );
+               return true;
+       }
+}
diff --git a/api/ApiQueryGadgetPages.php b/api/ApiQueryGadgetPages.php
new file mode 100644
index 0000000..2c9e240
--- /dev/null
+++ b/api/ApiQueryGadgetPages.php
@@ -0,0 +1,155 @@
+<?php
+/**
+ * Created on 15 April 2011
+ * API for Gadgets extension
+ *
+ * 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
+ */
+
+class ApiQueryGadgetPages extends ApiQueryGeneratorBase {
+       public function __construct( $query, $moduleName ) {
+               parent::__construct( $query, $moduleName, 'gp' );
+       }
+
+       public function getCacheMode( $params ) {
+               return 'public';
+       }
+
+       public function execute() {
+               $this->run();
+       }
+
+       /**
+        * @param $resultPageSet ApiPageSet
+        * @return void
+        */
+       public function executeGenerator( $resultPageSet ) {
+               $this->run( $resultPageSet );
+       }
+
+       /**
+        * @param $resultPageSet ApiPageSet
+        * @return void
+        */
+       private function run( $resultPageSet = null ) {
+               $params = $this->extractRequestParams();
+               $db = $this->getDB();
+
+               $this->addTables( 'page' );
+               $this->addFields( array( 'page_namespace', 'page_title' ) );
+               $this->addWhereFld( 'page_namespace', $params['namespace'] );
+               $like = array();
+               if ( $params['prefix'] !== null ) {
+                       $like[] = $this->titlePartToKey( $params['prefix'] );
+               }
+               $like[] = $db->anyString();
+               $like[] = ".{$params['extension']}";
+               $this->addWhere( 'page_title' . $db->buildLike( $like ) );
+               $limit = $params['limit'];
+               $this->addOption( 'LIMIT', $limit + 1 );
+               $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
+               $from = $params['from'] !== null ? $this->titlePartToKey( 
$params['from'] ) : null;
+               $this->addWhereRange( 'page_title', $dir, $from, null );
+
+               $res = $this->select( __METHOD__ );
+
+               $count = 0;
+               $titles = array();
+               $result = $this->getResult();
+               foreach ( $res as $row ) {
+                       if ( ++$count > $limit ) {
+                               // We've reached the one extra which shows that 
there are additional pages to be had. Stop here...
+                               // FIXME: keyToTitle is deprecated
+                               $this->setContinueEnumParameter( 'from', 
$this->keyToTitle( $row->page_title ) );
+                               break;
+                       }
+
+                       $title = Title::makeTitle( $row->page_namespace, 
$row->page_title );
+                       if ( is_null( $resultPageSet ) ) {
+                               $vals = array( 'pagename' => $title->getText() 
);
+                               self::addTitleInfo( $vals, $title );
+                               $fit = $result->addValue( array( 'query', 
$this->getModuleName() ), null, $vals );
+                               if ( !$fit ) {
+                                       $this->setContinueEnumParameter( 
'from', $this->keyToTitle( $row->page_title ) );
+                                       break;
+                               }
+                       } else {
+                               $titles[] = $title;
+                       }
+               }
+
+               if ( is_null( $resultPageSet ) ) {
+                       $result->addIndexedTagName( array( 'query', 
$this->getModuleName() ), 'p' );
+               } else {
+                       $resultPageSet->populateFromTitles( $titles );
+               }
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'extension' => array(
+                               ApiBase::PARAM_DFLT => 'js',
+                               ApiBase::PARAM_TYPE => array( 'js', 'css' ),
+                       ),
+                       'namespace' => array(
+                               ApiBase::PARAM_DFLT => NS_GADGET,
+                               ApiBase::PARAM_TYPE => 'namespace',
+                       ),
+                       'prefix' => null,
+                       'from' => null,
+                       'limit' => array(
+                               ApiBase::PARAM_DFLT => 10,
+                               ApiBase::PARAM_TYPE => 'limit',
+                               ApiBase::PARAM_MIN => 1,
+                               ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
+                               ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
+                       ),
+                       'dir' => array(
+                               ApiBase::PARAM_DFLT => 'ascending',
+                               ApiBase::PARAM_TYPE => array(
+                                       'ascending',
+                                       'descending'
+                               )
+                       ),
+               );
+       }
+
+       public function getDescription() {
+               return 'Returns a list of .js/.css pages';
+       }
+
+       public function getParamDescription() {
+               return array(
+                       'extension' => 'Search for pages with this extension.',
+                       'namespace' => 'Search for pages in this namespace.',
+                       'prefix' => array( 'Search for pages with this prefix 
(optional).',
+                                          'NOTE: Prefix does not include the 
namespace prefix',
+                       ),
+                       'from' => 'Start at this page title. NOTE: Does not 
include the namespace prefix',
+                       'limit' => 'How many total pages to return.',
+                       'dir' => 'The direction in which to list',
+               );
+       }
+
+       public function getExamples() {
+               return array(
+                       'Get a list of .js pages in the MediaWiki namespace:',
+                       '    
api.php?action=query&list=gadgetpages&gpextension=js&gpnamespace=8',
+                       "Get a list of .css pages in the Catrope's user space:",
+                       '    
api.php?action=query&list=gadgetpages&gpextension=css&gpnamespace=2&gpprefix=Catrope/',
+               );
+       }
+}
diff --git a/extension.json b/extension.json
index a3e4487..63a396b 100644
--- a/extension.json
+++ b/extension.json
@@ -73,6 +73,97 @@
                "GadgetDefinitionSecondaryDataUpdate": 
"includes/content/GadgetDefinitionSecondaryDataUpdate.php",
                "GadgetDefinitionDeletionUpdate": 
"includes/content/GadgetDefinitionDeletionUpdate.php"
        },
+       "ResourceFileModulePaths": {
+               "localBasePath": "modules",
+               "remoteExtPath": "Gadgets/modules"
+       },
+       "ResourceModules": {
+               "ext.gadgets.init": {
+                       "scripts": "ext.gadgets.init.js",
+                       "position": "top"
+               },
+               "ext.gadgets.specialgadgets.tabs": {
+                       "scripts": "ext.gadgets.specialgadgets.tabs.js",
+                       "messages": [
+                               "gadgets-gadget-create",
+                               "gadgets-gadget-create-tooltip"
+                       ],
+                       "dependencies": [
+                               "ext.gadgets.init",
+                               "mediawiki.util"
+                       ],
+                       "position": "top"
+               },
+               "ext.gadgets.api": {
+                       "scripts": "ext.gadgets.api.js",
+                       "dependencies": [
+                               "ext.gadgets.init",
+                               "mediawiki.Title",
+                               "mediawiki.util",
+                               "mediawiki.api",
+                               "json",
+                               "user.tokens"
+                       ]
+               },
+               "jquery.createPropCloud": {
+                       "scripts": "jquery.createPropCloud.js"
+               },
+               "ext.gadgets.gadgetmanager": {
+                       "scripts": "ext.gadgets.gadgetmanager.js",
+                       "styles": "ext.gadgets.gadgetmanager.css",
+                       "dependencies": [
+                               "ext.gadgets.init",
+                               "ext.gadgets.api",
+                               "jquery.localize",
+                               "jquery.ui.autocomplete",
+                               "jquery.ui.dialog",
+                               "jquery.createPropCloud",
+                               "jquery.spinner",
+                               "mediawiki.Title",
+                               "mediawiki.util",
+                               "mediawiki.api"
+                       ],
+                       "messages": [
+                               "gadgetmanager-editor-title-editing",
+                               "gadgetmanager-editor-title-creating",
+                               "gadgetmanager-editor-prop-remove",
+                               "gadgetmanager-editor-removeprop-tooltip",
+                               "gadgetmanager-editor-save",
+                               "gadgetmanager-editor-cancel",
+                               "gadgetmanager-prop-id",
+                               "gadgetmanager-prop-id-error-blank",
+                               "gadgetmanager-prop-id-error-illegal",
+                               "gadgetmanager-prop-id-error-taken",
+                               "colon-separator",
+                               "gadgetmanager-propsgroup-settings",
+                               "gadgetmanager-propsgroup-module",
+                               "gadgetmanager-prop-scripts",
+                               "gadgetmanager-prop-scripts-placeholder",
+                               "gadgetmanager-prop-styles",
+                               "gadgetmanager-prop-styles-placeholder",
+                               "gadgetmanager-prop-dependencies",
+                               "gadgetmanager-prop-messages",
+                               "gadgetmanager-prop-category",
+                               "gadgetmanager-prop-category-new",
+                               "gadgetmanager-prop-rights",
+                               "gadgetmanager-prop-default",
+                               "gadgetmanager-prop-hidden",
+                               "gadgetmanager-prop-shared",
+                               "gadgetmanager-comment-modify",
+                               "gadgetmanager-comment-create"
+                       ]
+               },
+               "ext.gadgets.preferences": {
+                       "scripts": "ext.gadgets.preferences.js",
+                       "dependencies": [
+                               "ext.gadgets.api"
+                       ],
+                       "messages": [
+                               "gadgets-sharedprefs-ajaxerror",
+                               "gadgets-preference-description"
+                       ]
+               }
+       },
        "Hooks": {
                "ArticleSaveComplete": [
                        "GadgetHooks::articleSaveComplete"
diff --git a/modules/ext.gadgets.api.js b/modules/ext.gadgets.api.js
new file mode 100644
index 0000000..0bd95a9
--- /dev/null
+++ b/modules/ext.gadgets.api.js
@@ -0,0 +1,331 @@
+/**
+ * Interface to the API for the gadgets extension.
+ *
+ * @author Timo Tijhof
+ * @author Roan Kattouw
+ * @license GNU General Public Licence 2.0 or later
+ */
+
+( function ( $, mw ) {
+       var
+               /**
+                * @var {Object} Keyed by object of gadget objects by id
+                * @example { { gadgetID: { title: .., metadata: ..}, otherId: 
{ .. } }
+                */
+               gadgetCache = {},
+               /**
+                * @var {Object} Keyed by repo, array of category objects
+                * @example { repoName: [ {name: .., title: .., members: .. }, 
{ .. }, { .. } ] }
+                */
+               gadgetCategoryCache = {};
+
+       /* Local functions */
+
+       /**
+        * For most returns from api.* functions, a clone is made when data from
+        * cache is used. This is to avoid situations where later modifications
+        * (e.g. by the AJAX editor) to the object affect the cache (because
+        * the object would otherwise be passed by reference).
+        */
+       function objClone( obj ) {
+               /*
+                * A normal `$.extend( {}, obj );` is not suffecient,
+                * it has to be recursive, because the values of this
+                * object are also refererenes to objects.
+                * Consider:
+                * <code>
+                *     var a = { words: [ 'foo', 'bar','baz' ] };
+                *     var b = $.extend( {}, a );
+                *     b.words.push( 'quux' );
+                *     a.words[3]; // quux !
+                * </code>
+                */
+               return $.extend( true /* recursive */, {}, obj );
+       }
+
+       function arrClone( arr ) {
+               return arr.slice();
+       }
+
+       /**
+        * Reformat an array of gadget objects, into an object keyed by the id.
+        * Note: Maintains object reference
+        * @param arr {Array}
+        * @return {Object}
+        */
+       function gadgetArrToObj( arr ) {
+               for( var obj = {}, i = 0, g = arr[i], len = arr.length; i < 
len; g = arr[++i] ) {
+                       obj[g.id] = g;
+               }
+               return obj;
+       }
+
+       /**
+        * Write data to gadgetCache, taking into account that id may be null
+        * and working around JS's annoying refusal to just let us do
+        * var foo = {}; foo[bar][baz] = quux;
+        *
+        * This sets gadgetCache[id] = data; if id is not null,
+        * or gadgetCache = data; if id is null.
+        *
+        * @param id {String|null} Gadget ID or null
+        * @param data {Object} Data to put in the cache
+        */
+       function cacheGadgetData( id, data ) {
+               if ( id === null ) {
+                       gadgetCache = data;
+               } else {
+                       gadgetCache[id] = data;
+               }
+       }
+
+       /**
+        * Call an asynchronous function for each repository, and merge
+        * their return values into an object keyed by repository name.
+        * @param getter function( success, error, repoName ), called for each 
repo to get the data
+        * @param success function( data ), called when all data has 
successfully been retrieved
+        * @param error function( error ), called if one of the getter calls 
called its error callback
+        */
+       function mergeRepositoryData( getter, success, error ) {
+               var combined = {}, successes = 0, numRepos = 0, repo, failed = 
false;
+               // Find out how many repos there are
+               // Needs to be in a separate loop because we have to have the 
final number ready
+               // before we fire the first potentially (since it could be 
cached) async request
+               for ( repo in mw.gadgets.conf.repos ) {
+                       numRepos++;
+               }
+
+               // Use $.each instead of a for loop so we can access repoName 
in the success callback
+               // without annoying issues
+               $.each( mw.gadgets.conf.repos, function ( repoName ) {
+                       getter(
+                               function ( data ) {
+                                       combined[repoName] = data;
+                                       if ( ++successes === numRepos ) {
+                                               success( combined );
+                                       }
+                               }, function ( errorCode ) {
+                                       if ( !failed ) {
+                                               failed = true;
+                                               error( errorCode );
+                                       }
+                               }, repoName
+                       );
+               } );
+       }
+
+       /* Public functions */
+
+       mw.gadgets.api = {
+                       /**
+                        * Get the gadget blobs for all gadgets from all 
repositories.
+                        *
+                        * @param success {Function} To be called with an 
object of arrays of gadget objects,
+                        * keyed by repository name, as first argument.
+                        * @param error {Function} To be called with a string 
(error code) as first argument.
+                        */
+                       getForeignGadgetsData: function ( success, error ) {
+                               mergeRepositoryData(
+                                       function ( s, e, repoName ) { 
mw.gadgets.api.getGadgetData( null, s, e, repoName ); },
+                                       success, error
+                               );
+                       },
+
+                       /**
+                        * Get the gadget categories from all repositories.
+                        *
+                        * @param success {Function} To be called with an array
+                        * @param success {Function} To be called with an 
object of arrays of category objects,
+                        * keyed by repository name, as first argument.
+                        * @param error {Function} To be called with a string 
(error code) as the first argument.
+                        */
+                       getForeignGadgetCategories: function ( success, error ) 
{
+                               mergeRepositoryData( 
mw.gadgets.api.getGadgetCategories, success, error );
+                       },
+
+                       /**
+                        * Get gadget blob from the API (or from cache if 
available).
+                        *
+                        * @param id {String|null} Gadget id, or null to get 
all from the repo.
+                        * @param success {Function} To be called with the 
gadget object or array
+                        * of gadget objects as first argument.
+                        * @param error {Function} If something went wrong 
(inexistent gadget, api
+                        * error, request error), this is called with error 
code as first argument.
+                        * @param repoName {String} Name of the repository, key 
in mw.gadgets.conf.repos.
+                        * Defaults to 'local'
+                        */
+                       getGadgetData: function ( id, success, error, repoName 
) {
+                               repoName = repoName || 'local';
+                               // Check cache
+                               if ( repoName in gadgetCache && 
gadgetCache[repoName] !== null ) {
+                                       if ( id === null ) {
+                                               success( objClone( 
gadgetCache[repoName] ) );
+                                               return;
+                                       } else if ( id in gadgetCache[repoName] 
&& gadgetCache[repoName][id] !== null ) {
+                                               success( objClone( 
gadgetCache[repoName][id] ) );
+                                               return;
+                                       }
+                               }
+                               // Get from API if not cached
+                               var queryData = {
+                                       list: 'gadgets',
+                                       gaprop: 
'id|title|desc|metadata|definitiontimestamp',
+                                       galanguage: mw.config.get( 
'wgUserLanguage' )
+                               };
+                               if ( id !== null ) {
+                                       queryData.gaids = id;
+                               }
+                               new mw.Api().get( queryData )
+                                       .done( function ( data ) {
+                                               data = data.query.gadgets;
+                                               if ( id !== null ) {
+                                                       data = data[0] || null;
+                                               } else {
+                                                       data = gadgetArrToObj( 
data );
+                                               }
+                                               // Update cache
+                                               cacheGadgetData( id, data );
+                                               success( objClone( data ) );
+                                       } )
+                                       .fail( function ( code ) {
+                                               // Invalidate cache
+                                               cacheGadgetData( id, null );
+                                               error( code );
+                                       } );
+                       },
+
+                       /**
+                        * Get the gadget categories for a certain repository 
from the API.
+                        *
+                        * @param success {Function} To be called with an array 
as first argument.
+                        * @param error {Function} To be called with a string 
(error code) as first argument.
+                        * @param repoName {String} Name of the repository, key 
in mw.gadgets.conf.repos.
+                        * Defaults to 'local'
+                        * @return {jqXHR|Null}: Null if served from cache, 
otherwise the jqXHR.
+                        */
+                       getGadgetCategories: function ( success, error, 
repoName ) {
+                               repoName = repoName || 'local';
+                               // Check cache
+                               if ( repoName in gadgetCategoryCache && 
gadgetCategoryCache[repoName] !== null ) {
+                                       success( arrClone( 
gadgetCategoryCache[repoName] ) );
+                                       return null;
+                               }
+                               // Get from API if not cached
+                               return new mw.Api( { ajax: { url: 
mw.gadgets.conf.repos[repoName].apiScript, dataType: 'jsonp' } } ).get( {
+                                       list: 'gadgetcategories',
+                                       gcprop: 'name|title|members',
+                                       gclanguage: mw.config.get( 
'wgUserLanguage' )
+                               } ).done( function ( data ) {
+                                       data = data.query.gadgetcategories;
+                                       // Update cache
+                                       gadgetCategoryCache[repoName] = data;
+                                       success( arrClone( data ) );
+                               } ).fail( function ( code ) {
+                                       // Invalidate cache
+                                       gadgetCategoryCache[repoName] = null;
+                                       error( code );
+                               } );
+                       },
+
+                       /**
+                        * Edits an existing gadget definition.
+                        *
+                        * @param gadget {Object}
+                        * - id {String} Id of the gadget to modify
+                        * - metadata {Object} Gadget meta data
+                        * @param o {Object} Additional options:
+                        * - starttimestamp {String} ISO_8601 timestamp of when 
user started editing
+                        * - success {Function} Called with one argument (API 
response object of the
+                        * 'edit' action)
+                        * - error {Function} Called with one argument (status 
from API if availabe,
+                        * otherwise, if the request failed, 'unknown' is given)
+                        * - extraquery {Object} Query parameters to add or 
overwrite the default.
+                        * @return {jqXHR}
+                        */
+                       doModifyGadget: function ( gadget, o ) {
+                               var t = new mw.Title(
+                                               gadget.id,
+                                               /*jshint camelcase: false*/ 
mw.config.get( 'wgNamespaceIds' ).gadget_definition
+                                       ),
+                                       query = {
+                                               action: 'edit',
+                                               title: t.getPrefixedDb(),
+                                               text: JSON.stringify( 
gadget.metadata ),
+                                               summary: mw.msg( 
'gadgetmanager-comment-modify', gadget.id ),
+                                               token: mw.user.tokens.get( 
'editToken' )
+                                       };
+                               // Optional, only include if passed
+                               if ( gadget.definitiontimestamp ) {
+                                       query.basetimestamp = 
gadget.definitiontimestamp;
+                               }
+                               if ( o.starttimestamp ) {
+                                       query.starttimestamp = o.starttimestamp;
+                               }
+                               // Allow more customization for 
mw.gadgets.api.doCreateGadget
+                               if ( o.extraquery ) {
+                                       $.extend( query, o.extraquery );
+                               }
+                               return new mw.Api().post( query ).done( 
function ( data ) {
+                                       // Invalidate cache
+                                       cacheGadgetData( gadget.id, null );
+                                       if ( data.edit.result === 'Success' ) {
+                                               o.success( data.edit );
+                                       } else {
+                                               o.error( data.edit.result );
+                                       }
+                               } ).fail( function ( code ) {
+                                       // Invalidate cache
+                                       cacheGadgetData( gadget.id, null );
+                                       o.error( code );
+                               } );
+                       },
+
+                       /**
+                        * Create a gadget by creating a gadget definition page.
+                        * Like mw.gadgets.api.doModifyGadget(), but to create 
a new gadget.
+                        * Will fail if gadget exists already.
+                        *
+                        * @param gadget {Object}
+                        * - id {String}
+                        * - metadata {Object}
+                        * @param o {Object} Additional options
+                        * @return {jqXHR}
+                        */
+                       doCreateGadget: function ( gadget, o ) {
+                               return mw.gadgets.api.doModifyGadget( gadget, 
$.extend( o, {
+                                       extraquery: {
+                                               createonly: 1,
+                                               summary: mw.msg( 
'gadgetmanager-comment-create', gadget.id )
+                                       }
+                               } ) );
+                       },
+
+                       /**
+                        * Deletes a gadget definition.
+                        *
+                        * @param id {String} Id of the gadget to delete.
+                        * @param callback {Function} Called with one argument 
(ok', 'error' or 'conflict').
+                        * @return {jqXHR}
+                        */
+                       doDeleteGadget: function ( id, success, error ) {
+                               // @todo ApiDelete
+                               // Invalidate cache
+                               cacheGadgetData( id, null );
+                               error( '@todo' );
+                               return null;
+                       },
+
+                       /**
+                        * Cache clearing
+                        * @return {Boolean}
+                        */
+                       clearGadgetCache: function () {
+                               gadgetCache = {};
+                               return true;
+                       },
+                       clearCatgoryCache: function () {
+                               gadgetCategoryCache = {};
+                               return true;
+                       }
+       };
+} )( jQuery, mediaWiki );
diff --git a/modules/ext.gadgets.gadgetmanager.css 
b/modules/ext.gadgets.gadgetmanager.css
new file mode 100644
index 0000000..356dbe5
--- /dev/null
+++ b/modules/ext.gadgets.gadgetmanager.css
@@ -0,0 +1,141 @@
+/**
+ * Styling for the gadget manager javascript-generated user interface.
+ */
+
+/**
+ * Form
+ */
+.mw-gadgetmanager-form fieldset {
+       margin: 0;
+}
+
+.mw-gadgetmanager-form table {
+       width: 100%;
+       border-spacing: 0;
+       border-collapse: collapse;
+}
+
+.mw-gadgetmanager-label {
+       width: 20%;
+}
+
+.mw-gadgetmanager-form td,
+.mw-gadgetmanager-form th {
+       vertical-align: top;
+       padding: 2px;
+}
+
+.mw-gadgetmanager-id-wrapper {
+       width: 100%;
+       margin: 10px 0;
+}
+
+.mw-gadgetmanager-id-wrapper label {
+       display: inline-block;
+       width: 30%;
+       text-align: right;
+}
+
+.mw-gadgetmanager-id {
+       margin: 0 5px 0 0;
+       padding: 3px 21px 3px 3px;
+       border: 1px solid grey;
+       background-color: white;
+}
+
+.mw-gadgetmanager-id-available {
+       /* @embed */
+       background: #E5F6E5 url(images/input-ok.png) 98% 50% no-repeat;
+}
+
+.mw-gadgetmanager-id-error {
+       background-color: #F6E5E5;
+}
+
+.mw-gadgetmanager-id.disabled {
+       background-color: transparent;
+       border-color: transparent;
+}
+
+.mw-gadgetmanager-id.loading {
+       /* @embed */
+       background: url(images/input-loading.gif) 98% 50% no-repeat;
+}
+
+.mw-gadgetmanager-id input {
+       margin: 0;
+       padding: 0;
+       display: inline-block;
+       width: 30%;
+       border: 0;
+       outline: 0;
+       background: transparent;
+}
+
+.mw-gadgetmanager-id-errorbox {
+       display: none;
+       line-height: 24px;
+       margin: 1em 1em 0.5em 30%;
+       padding: 0 0 0 35px;
+       /* @embed */
+       background: url(images/input-error.png) 1% 50% no-repeat;
+       font-weight: bold;
+}
+
+/**
+ * Gadget list
+ */
+.client-js .mw-gadgets-gadget:hover {
+       cursor: pointer;
+}
+
+/**
+ * The PropCloud
+ */
+.mw-gadgetmanager-propcloud {
+       overflow: hidden;
+       padding: 3px;
+       border: 1px solid grey;
+       background: white;
+}
+
+.mw-gadgetmanager-propcloud input {
+       width: 40%;
+}
+
+.mw-gadgetmanager-prop {
+       float: left;
+       margin: 2px 5px 5px 2px;
+       padding: 2px 5px;
+       background: #e5eff6;
+       border: 1px solid #a4d2fb;
+       border-radius: 10px;
+       line-height: 1;
+}
+
+.mw-gadgetmanager-prop-delete {
+       display: inline-block;
+       width: 10px;
+       height: 10px;
+       /* @embed */
+       background: url(images/close.png) 50% 50% no-repeat;
+       opacity: 0.4;
+}
+
+.mw-gadgetmanager-prop:hover .mw-gadgetmanager-prop-delete {
+       opacity: 0.7;
+}
+
+.mw-gadgetmanager-prop:hover .mw-gadgetmanager-prop-delete:hover {
+       opacity: 1;
+       cursor: pointer;
+}
+
+.mw-gadgetmanager-propinput,
+.mw-gadgetmanager-propinput:focus {
+       margin: 0;
+       padding: 0;
+       border: 0;
+       outline: 0;
+       background: transparent;
+}
diff --git a/modules/ext.gadgets.gadgetmanager.js 
b/modules/ext.gadgets.gadgetmanager.js
new file mode 100644
index 0000000..4ed3259
--- /dev/null
+++ b/modules/ext.gadgets.gadgetmanager.js
@@ -0,0 +1,615 @@
+/**
+ * JavaScript to initialize the UI of the gadget manager.
+ *
+ * @author Timo Tijhof, 2011 - 2012
+ * @license GNU General Public Licence 2.0 or later
+ */
+( function ( $, mw ) {
+
+       var
+               /**
+                * @var {Object} Local alias to mw.gadgets
+                */
+               ga = mw.gadgets,
+               /**
+                * @var {Object} HTML fragements
+                */
+               tpl = {
+                       fancyForm: '<form class="mw-gadgetmanager-form">\
+                                       <div 
class="mw-gadgetmanager-id-wrapper">\
+                                               <label 
for="mw-gadgetmanager-input-id"><html:msg key="gadgetmanager-prop-id" 
/><html:msg key="colon-separator" /></label>\
+                                               <span 
class="mw-gadgetmanager-id"><input type="text" id="mw-gadgetmanager-input-id" 
/></span>\
+                                               <div 
class="mw-gadgetmanager-id-errorbox"></div>\
+                                       </div>\
+                                       <fieldset>\
+                                               <legend><html:msg 
key="gadgetmanager-propsgroup-module" /></legend>\
+                                               <table>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-scripts"><html:msg key="gadgetmanager-prop-scripts" 
/></label></td>\
+                                                               <td><input 
type="text" id="mw-gadgetmanager-input-scripts" 
placeholder-msg="gadgetmanager-prop-scripts-placeholder" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-styles"><html:msg key="gadgetmanager-prop-styles" 
/></label></td>\
+                                                               <td><input 
type="text" id="mw-gadgetmanager-input-styles" 
placeholder-msg="gadgetmanager-prop-styles-placeholder" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-dependencies"><html:msg 
key="gadgetmanager-prop-dependencies" /></label></td>\
+                                                               <td><input 
type="text" id="mw-gadgetmanager-input-dependencies" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-messages"><html:msg 
key="gadgetmanager-prop-messages" /></label></td>\
+                                                               <td><input 
type="text" id="mw-gadgetmanager-input-messages" /></td>\
+                                                       </tr>\
+                                               </table>\
+                                       </fieldset>\
+                                       <fieldset>\
+                                               <legend><html:msg 
key="gadgetmanager-propsgroup-settings" /></legend>\
+                                               <table>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-category"><html:msg 
key="gadgetmanager-prop-category" /></label></td>\
+                                                               <td><select 
id="mw-gadgetmanager-input-category"></select><input type="text" 
id="mw-gadgetmanager-input-category-new" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-rights"><html:msg key="gadgetmanager-prop-rights" 
/></label></td>\
+                                                               <td><input 
type="text" id="mw-gadgetmanager-input-rights" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-default"><html:msg key="gadgetmanager-prop-default" 
/></label></td>\
+                                                               <td><input 
type="checkbox" id="mw-gadgetmanager-input-default" /></td>\
+                                                       </tr>\
+                                                       <tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-hidden"><html:msg 
key="gadgetmanager-prop-hidden"></label></td>\
+                                                               <td><input 
type="checkbox" id="mw-gadgetmanager-input-hidden" /></td>\
+                                                       </tr>\
+                                                       ' + ( 
ga.conf.enableSharing ? '<tr>\
+                                                               <td 
class="mw-gadgetmanager-label"><label 
for="mw-gadgetmanager-input-shared"><html:msg key="gadgetmanager-prop-shared" 
/></label></td>\
+                                                               <td><input 
type="checkbox" id="mw-gadgetmanager-input-shared" /></td>\
+                                                       </tr>\
+                                               ' : '' ) + '</table>\
+                                       </fieldset>\
+                               </form>'
+               },
+               /**
+                * @var {Object} Static cache for suggestions by script prefix.
+                */
+               suggestCacheScripts = {},
+               /**
+                * @var {Object} Static cache for suggestions by style prefix.
+                */
+               suggestCacheStyles = {},
+               /**
+                * @var {Object} Static cache for suggestions by messages 
prefix.
+                */
+               suggestCacheMsgs = {},
+               /**
+                * @var {Object} Complete static cache for module names. Lazy 
loaded from null.
+                */
+               suggestCacheDependencies = null,
+               /**
+                * @var {Object} Complete static cache for all rights.
+                */
+               suggestCacheRights = ga.conf.allRights,
+               /**
+                * @var {Number} Maximum number of autocomplete suggestions in 
the gadget editor input fields.
+                */
+               suggestLimit = 7,
+               nsGadgetId = mw.config.get( 'wgNamespaceIds' ).gadget,
+               nsSpecialId = mw.config.get( 'wgNamespaceIds' ).special;
+
+       /* Local functions */
+
+       /**
+        * Utility function to pad a zero
+        * to single digit number. Used by ISODateString().
+        * @param n {Number}
+        * @return {String|Number}
+        */
+       function pad( n ) {
+               return n < 10 ? '0' + n : n;
+       }
+       /**
+        * Format a date in an ISO 8601 format using UTC.
+        * 
https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Date#Example:_ISO_8601
+        *
+        * @param d {Date}
+        * @return {String}
+        */
+       function ISODateString( d ) {
+               return d.getUTCFullYear() + '-'
+               + pad( d.getUTCMonth() + 1 ) + '-'
+               + pad( d.getUTCDate() ) + 'T'
+               + pad( d.getUTCHours() ) + ':'
+               + pad( d.getUTCMinutes() ) + ':'
+               + pad( d.getUTCSeconds() ) + 'Z';
+       }
+       /**
+        * Validate a gadget id, which must be a valid
+        * title, as well as a valid module name.
+        * @param id {String}
+        * @return {Boolean}
+        */
+       function validateGadgetId( id ) {
+               return id.length
+                       && new mw.Title(
+                               id,
+                               /*jshint camelcase: false*/ mw.config.get( 
'wgNamespaceIds' ).gadget_definition
+                       ).getMainText() === id;
+       }
+       /**
+        * Toggle the state of the UI buttons in a dialog.
+        * @param $form {jQuery} jQuery.ui.widget from jquery.ui.dialog
+        * @param state {String} 'enable' or 'disable' (defaults to disable)
+        */
+       function toggleDialogButtons( $form, state ) {
+               $form.dialog( 'widget' ).find( 'button' ).button( state );
+       }
+
+       /* Public functions */
+
+       ga.ui = {
+               /**
+                * Initializes the the page. For now just binding click handlers
+                * to the anchor tags in the table.
+                */
+               initUI: function () {
+                       // Add ajax links
+                       $( '.mw-gadgets-gadgetlinks' ).each( function ( i, el ) 
{
+                               var $el = $( el );
+                               if ( 
ga.conf.userIsAllowed['gadgets-definition-edit'] ) {
+                                       $el.find( '.mw-gadgets-modify' ).click( 
function ( e ) {
+                                               e.preventDefault();
+                                               e.stopPropagation(); // To stop 
bubbling up to .mw-gadgets-gadget
+                                               ga.ui.startGadgetManager( 
'modify', $el.data( 'gadget-id' ) );
+                                       } );
+                               }
+                               if ( 
ga.conf.userIsAllowed['gadgets-definition-delete'] ) {
+                                       $el.find( '.mw-gadgets-delete' ).click( 
function ( /*e*/ ) {
+                                               //e.preventDefault();
+                                               //e.stopPropagation();
+                                               // @todo: Show delete action 
form
+                                       } );
+                               }
+                       } );
+
+                       // Entire gadget list item is clickable
+                       $( '.mw-gadgets-gadget' ).click( function ( e ) {
+                               e.preventDefault();
+                               e.stopPropagation();
+                               var t,
+                                       id = $( this ).data( 'gadget-id' );
+
+                               if ( 
ga.conf.userIsAllowed['gadgets-definition-edit'] ) {
+                                       ga.ui.startGadgetManager( 'modify', id 
);
+                                       return;
+                               }
+                               // Use localized special page name if possible 
to avoid redirect
+                               if ( mw.config.get( 
'wgCanonicalSpecialPageName' ) === 'Gadgets' ) {
+                                       t = new mw.Title( mw.config.get( 
'wgTitle' ) + '/' + id, nsSpecialId );
+                               } else {
+                                       t = new mw.Title( 'Gadgets/' + id, 
nsSpecialId );
+                               }
+                               window.location.href = t.getUrl();
+
+                       } ).find( 'a' ).click( function ( e ) {
+                               // Avoid other links becoming unclickable,
+                               // Don't let clicks on those bubble up
+                               e.stopPropagation();
+                       } );
+
+               },
+
+               /**
+                * Initialize the gadget manager dialog.
+                *
+                * @asynchronous
+                * @param mode {String} (See mw.gadgets.ui.getFancyForm)
+                * @param gadgetId {String}
+                */
+               startGadgetManager: function ( mode, gadgetId ) {
+
+                       // Ad hoc multi-loader. We need both requests, which 
are asynchronous,
+                       // to be complete. Which ever finishes first will set 
the local variable
+                       // to it's return value for the other callback to use.
+                       // @todo Notification: In case of an 'error'.
+                       var gadget, cats;
+
+
+                       if ( mode === 'create' ) {
+                               // New gadget, no need to query the api
+                               gadget = {
+                                       id: undefined,
+                                       metadata: {
+                                               settings: {
+                                                       rights: [],
+                                                       'default': false,
+                                                       hidden: false,
+                                                       shared: false,
+                                                       category: ''
+                                               },
+                                               module: {
+                                                       scripts: [],
+                                                       styles: [],
+                                                       dependencies: [],
+                                                       messages: []
+                                               }
+                                       },
+                                       definitiontimestamp: undefined,
+                                       title: undefined
+                               };
+                       } else {
+                               ga.api.getGadgetData( gadgetId, function ( ret 
) {
+                                       if ( cats ) {
+                                               // getGadgetCategories already 
done
+                                               return ga.ui.showFancyForm( 
ret, cats, mode );
+                                       }
+                                       // getGadgetCategories not done yet, 
leave gadget for it's callback to use
+                                       gadget = ret;
+                               } );
+                       }
+
+                       ga.api.getGadgetCategories( function ( ret ) {
+                               if ( gadget ) {
+                                       // getGadgetData already done
+                                       return ga.ui.showFancyForm( gadget, 
ret, mode );
+                               }
+                               // getGadgetData not done yet, leave cats for 
it's callback to use
+                               cats = ret;
+                       // Error callback. Fallback to empty array
+                       }, function () {
+                               if ( gadget ) {
+                                       // getGadgetData already done
+                                       return ga.ui.showFancyForm( gadget, [], 
mode );
+                               }
+                               // getGadgetData not done yet, leave cats for 
it's callback to use
+                               cats = [];
+                       } );
+               },
+
+               /**
+                * Generate form, create a dialog and open it into view.
+                *
+                * @param gadget {Object} Gadget object of the gadget to be 
modified.
+                * @param categories {Array} Gadget categories.
+                * @param mode {String} (See mw.gadgets.ui.getFancyForm)
+                * @return {jQuery} The (dialogged) form.
+                */
+               showFancyForm: function ( gadget, categories, mode ) {
+                       var $form = ga.ui.getFancyForm( gadget, categories, 
mode ),
+                               buttons = {};
+
+                       // Form submit
+                       buttons[mw.msg( 'gadgetmanager-editor-save' )] = 
function () {
+                               if ( mode === 'create' ) {
+                                       ga.api.doCreateGadget( gadget, {
+                                               success: function () {
+                                                       mw.log( 
'mw.gadgets.api.doModifyGadget: success', arguments );
+                                                       $form.dialog( 'close' );
+                                                       
window.location.reload();
+                                               },
+                                               error: function () {
+                                                       mw.log( 
'mw.gadgets.api.doModifyGadget: error', arguments );
+                                                       // @todo Notification: 
$formNotif.add( .. );
+                                               }
+                                       } );
+                               } else {
+                                       ga.api.doModifyGadget( gadget, {
+                                               starttimestamp: ISODateString( 
new Date() ),
+                                               success: function () {
+                                                       mw.log( 
'mw.gadgets.api.doModifyGadget: success', arguments );
+                                                       $form.dialog( 'close' );
+                                                       
window.location.reload();
+                                               },
+                                               error: function () {
+                                                       mw.log( 
'mw.gadgets.api.doModifyGadget: error', arguments );
+                                                       // @todo Notification: 
$formNotif.add( .. );
+                                               }
+                                       } );
+                               }
+                       };
+
+                       return $form
+                               .dialog({
+                                       autoOpen: true,
+                                       width: 800,
+                                       modal: true,
+                                       draggable: false,
+                                       resizable: false,
+                                       title: mode === 'create'
+                                               ? mw.message( 
'gadgetmanager-editor-title-creating' ).escaped()
+                                               : mw.message( 
'gadgetmanager-editor-title-editing', gadget.title ).escaped(),
+                                       buttons: buttons,
+                                       open: function () {
+                                               // Dialog is ready for action.
+                                               // Push out any notifications 
if some were queued up already between
+                                               // getting the gadget data and 
the display of the form.
+
+                                               // @todo Notification: 
$formNotif.add( .. );
+                                       }
+                               } );
+               },
+
+               /**
+                * Generate a <form> for the given module.
+                * Also binds events for submission and autocompletion.
+                *
+                * @param gadget {Object} Gadget object to read from and write 
to, used when saving
+                * the gadget metadata to the API.
+                * @param categories {Array} Gadget categories.
+                * @param mode {String} (optional) 'create' or 'modify' 
(defaults to 'modify')
+                * @return {jQuery} The form.
+                */
+               getFancyForm: function ( gadget, categories, mode ) {
+                       var metadata = gadget.metadata,
+                               $form = $( tpl.fancyForm ).localize(),
+                               $idSpan = $form.find( '.mw-gadgetmanager-id' ),
+                               $idErrMsg = $form.find( 
'.mw-gadgetmanager-id-errorbox' ),
+                               $newCatInput = $form.find( 
'#mw-gadgetmanager-input-category-new' );
+
+                       if ( mode === 'create' ) {
+
+                               // Validate
+                               $form.find( '#mw-gadgetmanager-input-id' 
).bind( 'keyup keypress keydown', function () {
+                                       var $el = $( this ),
+                                               val = $el.val();
+
+                                       // Reset
+                                       toggleDialogButtons( $form, 'enable' );
+                                       $idSpan.removeClass( 
'mw-gadgetmanager-id-error mw-gadgetmanager-id-available' );
+
+                                       // Abort if empty, don't warn when user 
is still typing,
+                                       // The onblur event handler takes care 
of that
+                                       if ( !val.length ) {
+                                               $idErrMsg.hide(); // Just in 
case
+                                               return;
+                                       }
+
+                                       // Auto-correct trim if needed 
(leading/trailing spaces are invalid)
+                                       // No need to raise errors just for 
that.
+                                       if ( $.trim( val ) !== val ) {
+                                               val = $.trim( val );
+                                               $el.val( val );
+                                       }
+
+                                       if ( validateGadgetId( val ) ) {
+                                               gadget.id = val;
+                                               $idErrMsg.hide();
+                                       } else {
+                                               toggleDialogButtons( $form, 
'disable' );
+                                               $idSpan.addClass( 
'mw-gadgetmanager-id-error' );
+                                               $idErrMsg.text( mw.msg( 
'gadgetmanager-prop-id-error-illegal' ) ).show();
+                                       }
+
+                               // Availability and non-empty check
+                               } ).blur( function () {
+                                       var val = $( this ).val();
+
+                                       // Reset
+                                       $idSpan.removeClass( 
'mw-gadgetmanager-id-error mw-gadgetmanager-id-available' );
+                                       toggleDialogButtons( $form, 'enable' );
+
+                                       if ( !val.length ) {
+                                               toggleDialogButtons( $form, 
'disable' );
+                                               $idSpan.addClass( 
'mw-gadgetmanager-id-error' );
+                                               $idErrMsg.text( mw.msg( 
'gadgetmanager-prop-id-error-blank' ) ).show();
+                                               return;
+                                       }
+
+                                       // Validity check here as well to avoid
+                                       // saying 'available' to an invalid  id.
+                                       if ( !validateGadgetId( val ) ) {
+                                               toggleDialogButtons( $form, 
'disable' );
+                                               $idSpan.addClass( 
'mw-gadgetmanager-id-error' );
+                                               $idErrMsg.text( mw.msg( 
'gadgetmanager-prop-id-error-illegal' ) ).show();
+                                               return;
+                                       }
+
+                                       ga.api.clearGadgetCache();
+
+                                       // asynchronous from here, show loading
+                                       $idSpan.addClass( 'loading' );
+
+                                       ga.api.getGadgetData( null, function ( 
data ) {
+                                               $idSpan.removeClass( 'loading' 
);
+                                               if ( val in data ) {
+                                                       toggleDialogButtons( 
$form, 'disable' );
+                                                       $idSpan.addClass( 
'mw-gadgetmanager-id-error' );
+                                                       $idErrMsg.text( mw.msg( 
'gadgetmanager-prop-id-error-taken' ) ).show();
+                                               } else {
+                                                       $idSpan.addClass( 
'mw-gadgetmanager-id-available' );
+                                                       $idErrMsg.hide();
+                                               }
+                                       } );
+                               } );
+
+
+                       } else {
+                               $form.find( '.mw-gadgetmanager-id input' ).val( 
gadget.id ).prop( 'disabled', true );
+                               $idSpan.addClass( 'disabled' );
+                       }
+
+
+                       // Module properties: scripts
+                       $form.find( '#mw-gadgetmanager-input-scripts' 
).createPropCloud({
+                               props: metadata.module.scripts,
+                               autocompleteSource: function ( data, response ) 
{
+                                       var lookup = mw.Title.newFromText( 
data.term ).getMain();
+                                       // Use cache if available
+                                       if ( lookup in suggestCacheScripts ) {
+                                               response( 
suggestCacheScripts[lookup] );
+                                               return;
+                                       }
+
+                                       new mw.Api().get( {
+                                               list: 'gadgetpages',
+                                               gpnamespace: nsGadgetId,
+                                               gpextension: 'js',
+                                               gpprefix: lookup
+                                       } ).done( function ( json ) {
+                                               var suggestions = 
json.query.gadgetpages.splice( 0, suggestLimit );
+                                               suggestions = $.map( 
suggestions, function ( val ) {
+                                                       return val.pagename;
+                                               } );
+
+                                               // Update cache
+                                               suggestCacheScripts[lookup] = 
suggestions;
+
+                                               response( suggestions );
+                                       } ).fail( function () {
+                                               response( [] );
+                                       } );
+                               },
+                               prefix: 'mw-gadgetmanager-',
+                               removeTooltip: mw.msg( 
'gadgetmanager-editor-removeprop-tooltip' )
+                       } );
+
+                       // Module properties: styles
+                       $form.find( '#mw-gadgetmanager-input-styles' 
).createPropCloud({
+                               props: metadata.module.styles,
+                               autocompleteSource: function ( data, response ) 
{
+                                       var lookup = mw.Title.newFromText( 
data.term ).getMain();
+                                       // Use cache if available
+                                       if ( lookup in suggestCacheStyles ) {
+                                               response( 
suggestCacheStyles[lookup] );
+                                               return;
+                                       }
+
+                                       new mw.Api().get( {
+                                               list: 'gadgetpages',
+                                               gpnamespace: nsGadgetId,
+                                               gpextension: 'css',
+                                               gpprefix: lookup
+                                       } ).done( function ( json ) {
+                                               var suggestions = $.map( 
json.query.gadgetpages, function ( val ) {
+                                                       return val.pagename;
+                                               } );
+                                               suggestions = 
suggestions.splice( 0, suggestLimit );
+
+                                               // Update cache
+                                               suggestCacheStyles[lookup] = 
suggestions;
+
+                                               response( suggestions );
+                                       } ).fail( function () {
+                                               response( [] );
+                                       } );
+                               },
+                               prefix: 'mw-gadgetmanager-',
+                               removeTooltip: mw.msg( 
'gadgetmanager-editor-removeprop-tooltip' )
+                       } );
+
+                       // Module properties: dependencies
+                       $form.find( '#mw-gadgetmanager-input-dependencies' 
).createPropCloud({
+                               props: metadata.module.dependencies,
+                               autocompleteSource: function ( data, response ) 
{
+                                       if ( suggestCacheDependencies === null 
) {
+                                               suggestCacheDependencies = 
mw.loader.getModuleNames();
+                                       }
+                                       var output = $.ui.autocomplete.filter( 
suggestCacheDependencies, data.term );
+                                       response( output.slice( 0, suggestLimit 
) );
+                               },
+                               prefix: 'mw-gadgetmanager-',
+                               removeTooltip: mw.msg( 
'gadgetmanager-editor-removeprop-tooltip' )
+                       } );
+
+                       // Module properties: messages
+                       $form.find( '#mw-gadgetmanager-input-messages' 
).createPropCloud({
+                               props: metadata.module.messages,
+                               autocompleteSource: function ( data, response ) 
{
+                                       // Use cache if available
+                                       if ( data.term in suggestCacheMsgs ) {
+                                               response( 
suggestCacheMsgs[data.term] );
+                                               return;
+                                       }
+                                       new mw.Api().get( {
+                                               meta: 'allmessages',
+                                               amprefix: data.term,
+                                               amnocontent: true,
+                                               amincludelocal: true,
+                                               amlang: mw.config.get( 
'wgContentLanguage' )
+                                       } ).done( function ( json ) {
+                                               var suggestions = $.map( 
json.query.allmessages, function ( val ) {
+                                                       return val.name;
+                                               } );
+                                               suggestions = 
suggestions.splice( 0, suggestLimit );
+
+                                               // Update cache
+                                               suggestCacheMsgs[data.term] = 
suggestions;
+
+                                               response( suggestions );
+                                       } ).fail( function () {
+                                               response( [] );
+                                       } );
+                               },
+                               prefix: 'mw-gadgetmanager-',
+                               removeTooltip: mw.msg( 
'gadgetmanager-editor-removeprop-tooltip' )
+                       } );
+
+                       // Gadget settings: category
+                       $form.find( '#mw-gadgetmanager-input-category' 
).append( function () {
+                               var current = metadata.settings.category,
+                                       opts = '',
+                                       i = 0,
+                                       catslen = categories.length,
+                                       cat;
+                               for ( ; i < catslen; i++ ) {
+                                       cat = categories[i];
+                                       opts += mw.html.element( 'option', {
+                                               value: cat.name,
+                                               selected: cat.name === current
+                                       }, cat.title );
+                               }
+                               opts += '<option 
disabled="disabled">-------</option>'
+                                       + '<option 
data-gadgets-new-category="true">' + mw.message( 
'gadgetmanager-prop-category-new' ).escaped() + '</option>';
+                               return opts;
+                       } ).bind( 'change', function () {
+                               if ( $( this ).children( ':selected' ).data( 
'gadgetsNewCategory' ) === true ) {
+                                       metadata.settings.category = 
$newCatInput.val();
+                                       $newCatInput.show().focus();
+                               } else {
+                                       metadata.settings.category = $( this 
).val();
+                                       $newCatInput.hide();
+                               }
+                       } );
+
+                       $newCatInput
+                               .hide()
+                               .prop( 'placeholder', mw.msg( 
'gadgetmanager-prop-category-new' ) )
+                               .bind( 'blur change', function () {
+                                       metadata.settings.category = $( this 
).val();
+                               } );
+
+                       // Gadget settings: rights
+                       $form.find( '#mw-gadgetmanager-input-rights' 
).createPropCloud({
+                               props: metadata.settings.rights,
+                               autocompleteSource: function ( data, response ) 
{
+                                       var output = $.ui.autocomplete.filter( 
suggestCacheRights, data.term );
+                                       response( output.slice( 0, suggestLimit 
) );
+                               },
+                               prefix: 'mw-gadgetmanager-',
+                               removeTooltip: mw.msg( 
'gadgetmanager-editor-removeprop-tooltip' )
+                       } );
+
+                       // Gadget settings: Default
+                       $form.find( '#mw-gadgetmanager-input-default' )
+                               .prop( 'checked', metadata.settings['default'] )
+                               .change( function () {
+                                       metadata.settings['default'] = 
this.checked;
+                               } );
+
+                       // Gadget settings: Hidden
+                       $form.find( '#mw-gadgetmanager-input-hidden' )
+                               .prop( 'checked', metadata.settings.hidden )
+                               .change( function () { metadata.settings.hidden 
= this.checked; } );
+
+                       // Gadget settings: Shared
+                       $form.find( '#mw-gadgetmanager-input-shared' )
+                               .prop( 'checked', metadata.settings.shared )
+                               .change( function () { metadata.settings.shared 
= this.checked; } );
+
+                       return $form;
+               }
+       };
+
+       // Launch on document ready
+       $( document ).ready( ga.ui.initUI );
+
+} )( jQuery, mediaWiki );
diff --git a/modules/ext.gadgets.init.js b/modules/ext.gadgets.init.js
new file mode 100644
index 0000000..be5ddb8
--- /dev/null
+++ b/modules/ext.gadgets.init.js
@@ -0,0 +1,10 @@
+/**
+ * Initialize the mw.gadgets object
+ */
+( function ( mw ) {
+
+       mw.gadgets = {
+               conf: mw.config.get( 'gadgetsConf' )
+       };
+
+}( mediaWiki ) );
diff --git a/modules/ext.gadgets.preferences.css 
b/modules/ext.gadgets.preferences.css
new file mode 100644
index 0000000..754524c
--- /dev/null
+++ b/modules/ext.gadgets.preferences.css
@@ -0,0 +1,5 @@
+/* @todo: spinner CSS */
+
+#mw-prefsection-gadgets table.mw-htmlform-nolabel td.mw-label {
+       width: 1px !important;
+}
diff --git a/modules/ext.gadgets.preferences.js 
b/modules/ext.gadgets.preferences.js
new file mode 100644
index 0000000..06eccb4
--- /dev/null
+++ b/modules/ext.gadgets.preferences.js
@@ -0,0 +1,170 @@
+/**
+ * JavaScript to populate the shared gadgets tab on the preferences page.
+ *
+ * @author Roan Kattouw
+ * @copyright © 2011 Roan Kattouw
+ * @license GNU General Public Licence 2.0 or later
+ */
+( function ( $, mw ) {
+       function hexEncode( s ) {
+               var i, c,
+                       retval = '';
+               for ( i = 0; i < s.length; i++ ) {
+                       c = s.charCodeAt( i ).toString( 16 );
+                       if ( c.length % 2 === 1 ) {
+                               c = '0' + c;
+                       }
+                       retval += c;
+               }
+               return retval;
+       }
+
+       /**
+        * Put the category names, gadget names and gadget descriptions from 
the foreign
+        * repositories in the shared gadgets preference form
+        */
+       function fixPreferenceForm( gadgetsByCategory, categoryNames ) {
+               var repo, category, gadget, g, labelHtml,
+                       catName, $category,
+                       $categories = {};
+
+               for ( repo in gadgetsByCategory ) {
+                       if ( repo === 'local' ) {
+                               // We don't need to fix local Gadgets, leave 
those alone
+                               continue;
+                       }
+
+                       for ( category in gadgetsByCategory[repo] ) {
+                               catName = categoryNames[repo][category];
+                               $category = $( '#mw-htmlform-gadgetcategory-' + 
hexEncode( repo ) + '-' + hexEncode( category ) );
+                               if ( catName in $categories ) {
+                                       // We have encountered this category 
name before.
+                                       // This means two foreign repos have a 
category with the
+                                       // same name. Merge them.
+
+                                       // Add all items from this fieldset to 
the other fieldset
+                                       // that we've already provisioned for 
this category
+                                       $categories[catName].append( 
$category.find( 'tr' ) );
+                                       // Remove the now-empty fieldset
+                                       $category.closest( 'fieldset' 
).remove();
+                               } else {
+                                       // Register this category name so we 
can find duplicates
+                                       $categories[catName] = $category;
+                                       // Change the displayed category name
+                                       $category.siblings( 'legend' ).text( 
catName );
+                               }
+
+                               // Change the display text of each gadget
+                               for ( gadget in 
gadgetsByCategory[repo][category] ) {
+                                       g = 
gadgetsByCategory[repo][category][gadget];
+                                       if ( g.desc === '' ) {
+                                               // Empty description, just use 
the title
+                                               labelHtml = mw.html.escape( 
g.title );
+                                       } else {
+                                               labelHtml = mw.msg( 
'gadgets-preference-description', g.title );
+                                               // Description needs to be put 
in manually because it's HTML
+                                               labelHtml = labelHtml.replace( 
'$2', g.desc );
+                                       }
+                                       $( '#mw-input-gadgetpref-' + hexEncode( 
gadget ) )
+                                               .siblings( 'label' )
+                                               .html( labelHtml );
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Displays an error on the shared gadgets preferences tab, between the 
intro text
+        * and the checkboxes. This also unhides the checkboxes container and 
removes the spinner,
+        * if applicable.
+        *
+        * @param msgKey {String} Message key of the error message
+        */
+       function showPreferenceFormError( msgKey ) {
+               var $table = $( '#mw-htmlform-gadgetsshared' ),
+                       $errorMsg = $( '<p>' ).addClass( 'error' ).text( 
mw.msg( msgKey ) );
+
+               $table
+                       .append( $( '<tr>' ).append( $( '<td>' ).attr( 
'colspan', 2 ).append( $errorMsg ) ) )
+                       // Unhide the container and remove the spinner
+                       .removeClass( 'mw-gadgetsshared-item-unloaded 
mw-ajax-loader' );
+       }
+
+       /**
+        * Reformat the output of mw.gadgets.api.getForeignGadgetsData() to
+        * suitable input for fixPreferenceForm()
+        *
+        * @param data {Object} { repo: { gadgetID: gadgetObj } }
+        * @return {Object} { repo: { categoryID: { gadgetID: gadgetObj } } }
+        */
+       function reformatGadgetData( data ) {
+               var retval = {}, repo, gadget, category;
+               for ( repo in data ) {
+                       retval[repo] = { '': {} }; // Make sure '' is first in 
the list, fixPreferenceForm() needs it to be
+                       for ( gadget in data[repo] ) {
+                               category = 
data[repo][gadget].metadata.settings.category;
+                               if ( retval[repo][category] === undefined ) {
+                                       retval[repo][category] = {};
+                               }
+                               retval[repo][category][gadget] = 
data[repo][gadget];
+                       }
+               }
+               return retval;
+       }
+
+       /**
+        * Reformat the output of mw.gadgets.api.getForeignGadgetCategories()
+        * to suitable input for fixPreferenceForm()
+        *
+        * @param data {Object} { repo: [ { name: categoryID, title: 
categoryTitle } ] }
+        * @return { repo: { categoryID: categoryTitle } }
+        */
+       function reformatCategoryMap( data ) {
+               var repo, i,
+                       retval = {};
+               for ( repo in data ) {
+                       retval[repo] = {};
+                       for ( i = 0; i < data[repo].length; i++ ) {
+                               retval[repo][data[repo][i].name] = 
data[repo][i].title;
+                       }
+               }
+               return retval;
+       }
+
+       $( function () {
+               var gadgetsByCategory = null, categoryNames = null, failed = 
false;
+
+               // TODO spinner
+
+               // Do AJAX requests and call fixPreferenceForm() when done
+               mw.gadgets.api.getForeignGadgetsData(
+                       function ( data ) {
+                               gadgetsByCategory = reformatGadgetData( data );
+                               if ( categoryNames !== null ) {
+                                       fixPreferenceForm( gadgetsByCategory, 
categoryNames );
+                               }
+                       },
+                       function () {
+                               if ( !failed ) {
+                                       failed = true;
+                                       showPreferenceFormError( 
'gadgets-sharedprefs-ajaxerror' );
+                               }
+                       }
+               );
+               mw.gadgets.api.getForeignGadgetCategories(
+                       function ( data ) {
+                               categoryNames = reformatCategoryMap( data );
+                               if ( gadgetsByCategory !== null ) {
+                                       fixPreferenceForm( gadgetsByCategory, 
categoryNames );
+                               }
+                       },
+                       function () {
+                               if ( !failed ) {
+                                       failed = true;
+                                       showPreferenceFormError( 
'gadgets-sharedprefs-ajaxerror' );
+                               }
+                       }
+               );
+       } );
+
+} )( jQuery, mediaWiki );
diff --git a/modules/ext.gadgets.specialgadgets.prejs.css 
b/modules/ext.gadgets.specialgadgets.prejs.css
new file mode 100644
index 0000000..3d322e8
--- /dev/null
+++ b/modules/ext.gadgets.specialgadgets.prejs.css
@@ -0,0 +1,63 @@
+/* Gadget list */
+.mw-gadgets-list {
+       width: 100%;
+       border-bottom: 1px solid #ccc;
+}
+
+.mw-gadgets-gadget {
+       overflow: hidden;
+       position: relative;
+       padding: 0.5em;
+       border: 1px solid #ccc;
+       border-bottom: 0;
+}
+
+.mw-gadgets-gadget:hover {
+       background: #f9f9ff;
+}
+
+.mw-gadgets-title {
+       font-weight: bold;
+       min-height: 1.6em;
+}
+
+/* Tool links */
+
+.mw-gadgets-messagelink {
+       font-size: 75%;
+       font-weight: normal;
+}
+
+.mw-gadgets-messagelink a {
+       padding-left: 18px;
+       /* @embed */
+       background-image: url(images/edit-faded.png);
+       background-position: left top;
+       background-repeat: no-repeat;
+}
+
+.mw-gadgets-messagelink a:hover {
+       /* @embed */
+       background-image: url(images/edit.png);
+}
+
+.mw-gadgets-gadgetlinks {
+       float: right;
+       height: 1.6em;
+       padding: 0 0 0.5em 0.5em;
+       font-size: 75%;
+       font-weight: normal;
+}
+
+.mw-gadgets-gadgetlinks {
+       float: right;
+}
+
+.mw-gadgets-gadgetlinks a {
+       margin: 0 9px;
+}
+
+/* Export */
+.mw-gadgets-exportform fieldset {
+       max-width: 50%;
+}
diff --git a/modules/ext.gadgets.specialgadgets.tabs.js 
b/modules/ext.gadgets.specialgadgets.tabs.js
new file mode 100644
index 0000000..0f4dcb3
--- /dev/null
+++ b/modules/ext.gadgets.specialgadgets.tabs.js
@@ -0,0 +1,29 @@
+/**
+ * JavaScript for Special:Gadgets
+ *
+ * @author Timo Tijhof
+ */
+
+( function ( $, mw ) {
+       $( function () {
+               var ga = mw.gadgets;
+
+               if ( ga.conf.userIsAllowed['gadgets-definition-create'] ) {
+                       var createTab = mw.util.addPortletLink(
+                               // Not all skins use the new separated tabs yet,
+                               // Fall back to the general 'p-cactions'.
+                               $( '#p-views' ).length ? 'p-views' : 
'p-cactions',
+                               '#',
+                               mw.msg( 'gadgets-gadget-create' ),
+                               'ca-create', // Use whatever core has for pages 
? Or use gadget-create ?
+                               mw.msg( 'gadgets-gadget-create-tooltip' ),
+                               'e' // Same as core for ca-edit/ca-create
+                       );
+                       $( createTab ).click( function ( e ) {
+                               e.preventDefault();
+                               ga.ui.startGadgetManager( 'create' );
+                       } );
+               }
+
+       } );
+} )( jQuery, mediaWiki );
diff --git a/modules/images/close.png b/modules/images/close.png
new file mode 100644
index 0000000..8e57587
--- /dev/null
+++ b/modules/images/close.png
Binary files differ
diff --git a/modules/images/edit-faded.png b/modules/images/edit-faded.png
new file mode 100644
index 0000000..0f622e1
--- /dev/null
+++ b/modules/images/edit-faded.png
Binary files differ
diff --git a/modules/images/edit.png b/modules/images/edit.png
new file mode 100644
index 0000000..ec02a98
--- /dev/null
+++ b/modules/images/edit.png
Binary files differ
diff --git a/modules/images/input-error.png b/modules/images/input-error.png
new file mode 100644
index 0000000..aa63368
--- /dev/null
+++ b/modules/images/input-error.png
Binary files differ
diff --git a/modules/images/input-loading.gif b/modules/images/input-loading.gif
new file mode 100644
index 0000000..085ccae
--- /dev/null
+++ b/modules/images/input-loading.gif
Binary files differ
diff --git a/modules/images/input-ok.png b/modules/images/input-ok.png
new file mode 100644
index 0000000..2fc19fc
--- /dev/null
+++ b/modules/images/input-ok.png
Binary files differ
diff --git a/modules/jquery.createPropCloud.js 
b/modules/jquery.createPropCloud.js
new file mode 100644
index 0000000..a44efe8
--- /dev/null
+++ b/modules/jquery.createPropCloud.js
@@ -0,0 +1,142 @@
+/**
+ * jQuery PropCloud plugin
+ * @author Timo Tijhof, 2011
+ */
+( function ( $ ) {
+
+       /**
+        * Remove all occurences of a value from an array.
+        *
+        * @param arr {Array} Array to be changed
+        * @param val {Mixed} Value to be removed from the array
+        * @return {Array} May or may not be changed, reference kept
+        */
+       function arrayRemove( arr, val ) {
+               var i;
+               // Parentheses are crucial here. Without them, var i will be a
+               // boolean instead of a number, resulting in an infinite loop!
+               while ( ( i = arr.indexOf( val ) ) !== -1 ) {
+                       arr.splice( i, 1 );
+               }
+               return arr;
+       }
+
+       /**
+        * Create prop-element for a given label.
+        *
+        * @param label {String}
+        * @param o {Object} $.fn.createPropCloud options object
+        * @return {jQuery} <span class="(prefix)prop"> .. </span>
+        */
+       function newPropHtml( label, o ) {
+               return $( '<span>' ).addClass( o.prefix + 'prop' )
+                       .append(
+                               $( '<span>' ).addClass( o.prefix + 'prop-label' 
).text( label )
+                       )
+                       .append(
+                               $( '<span>' )
+                                       .addClass( o.prefix + 'prop-delete' )
+                                       .attr( 'title', o.removeTooltip )
+                                       .click( function () {
+                                               // Update UI
+                                               $(this).parent().remove();
+                                               // Update props
+                                               arrayRemove( o.props, label );
+                                               // Callback
+                                               o.onRemove( label );
+                                       }
+                               )
+                       );
+       }
+
+       /**
+        * Create prop cloud around an input field.
+        *
+        * @example This is the HTML structure being created:
+        *
+        * <div class="editor-propcloud">
+        *     <div class="editor-propcontainer">
+        *         <span class="editor-prop">
+        *             <span class="editor-prop-label"> .. </span>
+        *             <span class="editor-prop-delete" title="Remove this 
item"></span>
+        *         </span>
+        *         <span class="editor-prop">
+        *             <span class="editor-prop-label"> .. </span>
+        *             <span class="editor-prop-delete" title="Remove this 
item"></span>
+        *         </span>
+        *     </div>
+        *     <input class="editor-propinput" />
+        * </div>
+        *
+        * @context {jQuery}
+        * @param o {Object} All optional
+        *  - prefix {String} Class name prefix
+        *  - props {Array} Array of properties to start with
+        *  - autocompleteSource {Function|Array} Source of autocomplete 
suggestions (required)
+        *    See also http://jqueryui.com/demos/autocomplete/#options (source)
+        *  - onAdd {Function} Callback for when an item is added.
+        *    Called with one argument (the value).
+        *  - onRemove {Function} Callback for when an item is removed.
+        *    Called with one argument (the value).
+        *  - removeTooltip {String} Tooltip for the remove-icon
+        *
+        * @return {jQuery} prop cloud (input field inside)
+        */
+       $.fn.createPropCloud = function ( o ) {
+               // Some defaults
+               o = $.extend({
+                       prefix: 'editor-',
+                       props: [],
+                       autocompleteSource: [],
+                       onAdd: function () {},
+                       onRemove: function () {},
+                       removeTooltip: 'Remove this item'
+               }, o );
+
+               var $el = this.eq(0),
+                       $input = $el.addClass( o.prefix + 'propinput' ),
+                       $cloud = $input.wrap( '<div>' ).parent().addClass( 
o.prefix + 'propcloud' ),
+                       $container = $( '<div>' ).addClass( o.prefix + 
'propcontainer' ),
+                       i, props, len;
+
+               // Append while container is still off the DOM
+               // This is faster and prevents visible build-up
+               for ( i = 0, props = o.props, len = props.length; i < len; i++ 
) {
+                       $container.append( newPropHtml( '' + props[i], o ) );
+               }
+
+               $input.autocomplete( {
+                       // The source is entirely up to you
+                       source: o.autocompleteSource,
+
+                       // A value is choosen
+                       // (e.g. by pressing return/tab, clicking on 
suggestion, etc.)
+                       select: function ( e, data ){
+                               var val = data.item.value;
+
+                               // Prevent duplicate values
+                               if ( o.props.indexOf( val ) === -1 ) {
+                                       // Update UI
+                                       $container.append( newPropHtml( val, o 
) );
+                                       // Update props
+                                       o.props.push( val );
+                                       // Callback for custom stuff
+                                       o.onAdd( val );
+                               }
+
+                               // Clear input whether duplicate (and ignored),
+                               // or unique (and added to the PropCloud by now)
+                               $input.val( '' );
+
+                               // Return false,
+                               // otherwise jQuery UI calls .val( val ) again
+                               return false;
+                       }
+               });
+
+               $cloud.prepend( $container );
+
+               return $cloud;
+       };
+
+})( jQuery );

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

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ibaf43dd5d147277b61702a34e7332437c3395c17
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/Gadgets
Gerrit-Branch: master
Gerrit-Owner: Legoktm <[email protected]>

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

Reply via email to