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() )
+ . '   ' . $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() . ' ' .
$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 ) . '   ' . $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 .= '< ';
+ }
+
+ $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