diff --git a/src/lang_codes.c b/src/lang_codes.c
index ceb85614..abde7c2a 100644
--- a/src/lang_codes.c
+++ b/src/lang_codes.c
@@ -554,9 +554,35 @@ const lang_code_t *lang_code_get3 ( const char *code )
}
const char **lang_code_split ( const char *codes )
+{
+ int i = 0;
+ const lang_code_t **lcs = lang_code_split2(codes);
+ const char **ret;
+
+ if(!lcs) return NULL;
+
+ while(lcs[i])
+ i++;
+
+ ret = calloc(1+i, sizeof(char*));
+
+ i = 0;
+ while(lcs[i]) {
+ ret[i] = lcs[i]->code2b;
+ i++;
+ }
+ ret[i] = NULL;
+ free(lcs);
+
+ return ret;
+}
+
+const lang_code_t **lang_code_split2 ( const char *codes )
{
int n;
- const char *c, *p, **ret;
+ const char *c, *p;
+ const lang_code_t **ret;
+ const lang_code_t *co;
/* Defaults */
if (!codes) codes = config_get_language();
@@ -571,19 +597,21 @@ const char **lang_code_split ( const char *codes )
if (*c == ',') n++;
c++;
}
- ret = calloc(2+n, sizeof(char*));
+ ret = calloc(2+n, sizeof(lang_code_t*));
/* Create list */
n = 0;
p = c = codes;
while (*c) {
if (*c == ',') {
- ret[n++] = lang_code_get(p);
+ co = lang_code_get3(p);
+ if(co)
+ ret[n++] = co;
p = c + 1;
}
c++;
}
- if (*p) ret[n++] = lang_code_get(p);
+ if (*p) ret[n++] = lang_code_get3(p);
ret[n] = NULL;
return ret;
diff --git a/src/lang_codes.h b/src/lang_codes.h
index 7bfaf3f9..0a79fc19 100644
--- a/src/lang_codes.h
+++ b/src/lang_codes.h
@@ -36,5 +36,6 @@ const lang_code_t *lang_code_get3 ( const char *code );
/* Split list of codes as per HTTP Language-Accept spec */
const char **lang_code_split ( const char *codes );
+const lang_code_t **lang_code_split2 ( const char *codes );
#endif /* __TVH_LANG_CODES_H__ */
diff --git a/src/webui/extjs.c b/src/webui/extjs.c
index b4432c64..9c419001 100644
--- a/src/webui/extjs.c
+++ b/src/webui/extjs.c
@@ -1,6 +1,6 @@
/*
* tvheadend, EXTJS based interface
- * Copyright (C) 2008 Andreas Öman
+ * Copyright (C) 2008 Andreas Öman
*
* 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
@@ -43,17 +43,19 @@
#include "epg.h"
#include "muxer.h"
#include "iptv_input.h"
-
+#include "epggrab/private.h"
#include "config2.h"
+#include "lang_codes.h"
+/**
+ *
+ */
static void
extjs_load(htsbuf_queue_t *hq, const char *script)
{
htsbuf_qprintf(hq,
- "\n", script);
-
+ "\n", script);
}
/**
@@ -73,7 +75,6 @@ extjs_exec(htsbuf_queue_t *hq, const char *fmt, ...)
htsbuf_qprintf(hq, "\r\n\r\n");
}
-
/**
* PVR info, deliver info about the given PVR entry
*/
@@ -107,6 +108,8 @@ extjs_root(http_connection_t *hc, const char *remain, void *opaque)
extjs_load(hq, "static/app/extensions.js");
extjs_load(hq, "static/livegrid/livegrid-all.js");
extjs_load(hq, "static/lovcombo/lovcombo-all.js");
+ extjs_load(hq, "static/multiselect/multiselect.js");
+ extjs_load(hq, "static/multiselect/ddview.js");
/**
* Create a namespace for our app
@@ -172,7 +175,6 @@ extjs_root(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
/**
*
*/
@@ -205,7 +207,6 @@ page_about(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
/**
*
*/
@@ -302,8 +303,6 @@ extjs_channels_delete(htsmsg_t *in)
channel_delete(ch);
}
-#include "epggrab/private.h"
-
/**
*
*/
@@ -483,7 +482,6 @@ extjs_channels(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
/**
* EPG Content Groups
*/
@@ -519,7 +517,6 @@ json_single_record(htsmsg_t *rec, const char *root)
}
-
/**
*
*/
@@ -618,7 +615,6 @@ extjs_epggrab(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
/**
*
*/
@@ -711,6 +707,66 @@ skip:
}
+/**
+ *
+ */
+static int
+extjs_languages(http_connection_t *hc, const char *remain, void *opaque)
+{
+ htsbuf_queue_t *hq = &hc->hc_reply;
+ const char *op = http_arg_get(&hc->hc_req_args, "op");
+ htsmsg_t *out, *array, *e;
+
+ pthread_mutex_lock(&global_lock);
+
+ if(op != NULL && !strcmp(op, "list")) {
+
+ out = htsmsg_create_map();
+ array = htsmsg_create_list();
+
+ const lang_code_t *c = lang_codes;
+ while (c->code2b) {
+ e = htsmsg_create_map();
+ htsmsg_add_str(e, "identifier", c->code2b);
+ htsmsg_add_str(e, "name", c->desc);
+ htsmsg_add_msg(array, NULL, e);
+ c++;
+ }
+ }
+ else if(op != NULL && !strcmp(op, "config")) {
+
+ out = htsmsg_create_map();
+ array = htsmsg_create_list();
+
+ const lang_code_t **c = lang_code_split2(NULL);
+ if(c) {
+ int i = 0;
+ while (c[i]) {
+ e = htsmsg_create_map();
+ htsmsg_add_str(e, "identifier", c[i]->code2b);
+ htsmsg_add_str(e, "name", c[i]->desc);
+ htsmsg_add_msg(array, NULL, e);
+ i++;
+ }
+ free(c);
+ }
+ }
+ else {
+ pthread_mutex_unlock(&global_lock);
+ return HTTP_STATUS_BAD_REQUEST;
+ }
+
+ pthread_mutex_unlock(&global_lock);
+
+ htsmsg_add_msg(out, "entries", array);
+
+ htsmsg_json_serialize(out, hq, 0);
+ htsmsg_destroy(out);
+ http_output_content(hc, "text/x-json; charset=UTF-8");
+ return 0;
+
+}
+
/**
*
*/
@@ -909,6 +965,9 @@ extjs_epgrelated(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
+/**
+ *
+ */
static int
extjs_epgobject(http_connection_t *hc, const char *remain, void *opaque)
{
@@ -1201,7 +1260,6 @@ extjs_dvr(http_connection_t *hc, const char *remain, void *opaque)
}
-
/**
*
*/
@@ -1308,8 +1366,6 @@ extjs_dvrlist(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
-
/**
*
*/
@@ -1327,7 +1383,6 @@ extjs_service_delete(htsmsg_t *in)
}
}
-
/**
*
*/
@@ -1364,7 +1419,6 @@ service_update(htsmsg_t *in)
}
}
-
/**
*
*/
@@ -1456,8 +1510,6 @@ extjs_servicedetails(http_connection_t *hc,
return 0;
}
-
-
/**
*
*/
@@ -1501,8 +1553,6 @@ extjs_mergechannel(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
-
/**
*
*/
@@ -1550,8 +1600,6 @@ service_update_iptv(htsmsg_t *in)
}
}
-
-
/**
*
*/
@@ -1669,8 +1717,6 @@ extjs_iptvservices(http_connection_t *hc, const char *remain, void *opaque)
return 0;
}
-
-
/**
*
*/
@@ -1707,7 +1753,6 @@ extjs_service_update(htsmsg_t *in)
}
}
-
/**
*
*/
@@ -1801,32 +1846,25 @@ extjs_config(http_connection_t *hc, const char *remain, void *opaque)
void
extjs_start(void)
{
- http_path_add("/about.html", NULL, page_about, ACCESS_WEB_INTERFACE);
- http_path_add("/extjs.html", NULL, extjs_root, ACCESS_WEB_INTERFACE);
- http_path_add("/tablemgr", NULL, extjs_tablemgr, ACCESS_WEB_INTERFACE);
- http_path_add("/channels", NULL, extjs_channels, ACCESS_WEB_INTERFACE);
- http_path_add("/epggrab", NULL, extjs_epggrab, ACCESS_WEB_INTERFACE);
- http_path_add("/channeltags", NULL, extjs_channeltags, ACCESS_WEB_INTERFACE);
- http_path_add("/confignames", NULL, extjs_confignames, ACCESS_WEB_INTERFACE);
- http_path_add("/epg", NULL, extjs_epg, ACCESS_WEB_INTERFACE);
- http_path_add("/epgrelated", NULL, extjs_epgrelated, ACCESS_WEB_INTERFACE);
- http_path_add("/epgobject", NULL, extjs_epgobject, ACCESS_WEB_INTERFACE);
- http_path_add("/dvr", NULL, extjs_dvr, ACCESS_WEB_INTERFACE);
- http_path_add("/dvrlist", NULL, extjs_dvrlist, ACCESS_WEB_INTERFACE);
- http_path_add("/ecglist", NULL, extjs_ecglist, ACCESS_WEB_INTERFACE);
- http_path_add("/config", NULL, extjs_config, ACCESS_WEB_INTERFACE);
-
- http_path_add("/mergechannel",
- NULL, extjs_mergechannel, ACCESS_ADMIN);
-
- http_path_add("/iptv/services",
- NULL, extjs_iptvservices, ACCESS_ADMIN);
-
- http_path_add("/servicedetails",
- NULL, extjs_servicedetails, ACCESS_ADMIN);
-
- http_path_add("/tv/adapter",
- NULL, extjs_tvadapter, ACCESS_ADMIN);
+ http_path_add("/about.html", NULL, page_about, ACCESS_WEB_INTERFACE);
+ http_path_add("/extjs.html", NULL, extjs_root, ACCESS_WEB_INTERFACE);
+ http_path_add("/tablemgr", NULL, extjs_tablemgr, ACCESS_WEB_INTERFACE);
+ http_path_add("/channels", NULL, extjs_channels, ACCESS_WEB_INTERFACE);
+ http_path_add("/epggrab", NULL, extjs_epggrab, ACCESS_WEB_INTERFACE);
+ http_path_add("/channeltags", NULL, extjs_channeltags, ACCESS_WEB_INTERFACE);
+ http_path_add("/confignames", NULL, extjs_confignames, ACCESS_WEB_INTERFACE);
+ http_path_add("/epg", NULL, extjs_epg, ACCESS_WEB_INTERFACE);
+ http_path_add("/epgrelated", NULL, extjs_epgrelated, ACCESS_WEB_INTERFACE);
+ http_path_add("/epgobject", NULL, extjs_epgobject, ACCESS_WEB_INTERFACE);
+ http_path_add("/dvr", NULL, extjs_dvr, ACCESS_WEB_INTERFACE);
+ http_path_add("/dvrlist", NULL, extjs_dvrlist, ACCESS_WEB_INTERFACE);
+ http_path_add("/ecglist", NULL, extjs_ecglist, ACCESS_WEB_INTERFACE);
+ http_path_add("/config", NULL, extjs_config, ACCESS_WEB_INTERFACE);
+ http_path_add("/languages", NULL, extjs_languages, ACCESS_WEB_INTERFACE);
+ http_path_add("/mergechannel", NULL, extjs_mergechannel, ACCESS_ADMIN);
+ http_path_add("/iptv/services", NULL, extjs_iptvservices, ACCESS_ADMIN);
+ http_path_add("/servicedetails", NULL, extjs_servicedetails, ACCESS_ADMIN);
+ http_path_add("/tv/adapter", NULL, extjs_tvadapter, ACCESS_ADMIN);
#if ENABLE_LINUXDVB
extjs_start_dvb();
diff --git a/src/webui/static/app/config.js b/src/webui/static/app/config.js
index 37ed6913..2c9b6a86 100644
--- a/src/webui/static/app/config.js
+++ b/src/webui/static/app/config.js
@@ -1,3 +1,36 @@
+// Store: config languages
+tvheadend.languages = new Ext.data.JsonStore({
+ autoLoad:true,
+ root:'entries',
+ fields: ['identifier','name'],
+ id: 'identifier',
+ url:'languages',
+ baseParams: {
+ op: 'list'
+ }
+});
+
+// Store: all languages
+tvheadend.config_languages = new Ext.data.JsonStore({
+ autoLoad:true,
+ root:'entries',
+ fields: ['identifier','name'],
+ id: 'identifier',
+ url:'languages',
+ baseParams: {
+ op: 'config'
+ }
+});
+
+tvheadend.languages.setDefaultSort('name', 'ASC');
+
+tvheadend.comet.on('config', function(m) {
+ if(m.reload != null) {
+ tvheadend.languages.reload();
+ tvheadend.config_languages.reload();
+ }
+});
+
tvheadend.miscconf = function() {
/*
* Basic Config
@@ -13,13 +46,23 @@ tvheadend.miscconf = function() {
var dvbscanPath = new Ext.form.TextField({
fieldLabel : 'DVB scan files path',
name : 'muxconfpath',
- allowBlank : true
+ allowBlank : true,
+ width: 400
});
- var language = new Ext.form.TextField({
- fieldLabel : 'Default Language(s)',
- name : 'language',
- allowBlank : true
+ var language = new Ext.ux.ItemSelector({
+ name: 'language',
+ fromStore: tvheadend.languages,
+ toStore: tvheadend.config_languages,
+ fieldLabel: 'Default Language(s)',
+ dataFields:['identifier', 'name'],
+ msWidth: 190,
+ msHeight: 150,
+ valueField: 'identifier',
+ displayField: 'name',
+ imagePath: 'static/multiselect/resources',
+ toLegend: 'Selected',
+ fromLegend: 'Available'
});
/* ****************************************************************
@@ -40,7 +83,7 @@ tvheadend.miscconf = function() {
}
});
- var confpanel = new Ext.FormPanel({
+ var confpanel = new Ext.form.FormPanel({
title : 'General',
iconCls : 'wrench',
border : false,
diff --git a/src/webui/static/multiselect/ddview.js b/src/webui/static/multiselect/ddview.js
new file mode 100644
index 00000000..e63a67ad
--- /dev/null
+++ b/src/webui/static/multiselect/ddview.js
@@ -0,0 +1,551 @@
+Array.prototype.contains = function(element) {
+ return this.indexOf(element) !== -1;
+};
+
+Ext.namespace("Ext.ux");
+
+/**
+ * @class Ext.ux.DDView
+ * A DnD enabled version of Ext.View.
+ * @param {Element/String} container The Element in which to create the View.
+ * @param {String} tpl The template string used to create the markup for each element of the View
+ * @param {Object} config The configuration properties. These include all the config options of
+ * {@link Ext.View} plus some specific to this class.
+ *
+ * Drag/drop is implemented by adding {@link Ext.data.Record}s to the target DDView. If copying is
+ * not being performed, the original {@link Ext.data.Record} is removed from the source DDView.
+ *
+ * The following extra CSS rules are needed to provide insertion point highlighting:
+.x-view-drag-insert-above {
+ border-top:1px dotted #3366cc;
+}
+.x-view-drag-insert-below {
+ border-bottom:1px dotted #3366cc;
+}
+
+ *
+ */
+Ext.ux.DDView = function(config) {
+ if (!config.itemSelector) {
+ var tpl = config.tpl;
+ if (this.classRe.test(tpl)) {
+ config.tpl = tpl.replace(this.classRe, 'class=$1x-combo-list-item $2$1');
+ }
+ else {
+ config.tpl = tpl.replace(this.tagRe, '$1 class="x-combo-list-item" $2');
+ }
+ config.itemSelector = ".x-combo-list-item";
+ }
+ Ext.ux.DDView.superclass.constructor.call(this, Ext.apply(config, {
+ border: false
+ }));
+};
+
+Ext.extend(Ext.ux.DDView, Ext.DataView, {
+/** @cfg {String/Array} dragGroup The ddgroup name(s) for the View's DragZone. */
+/** @cfg {String/Array} dropGroup The ddgroup name(s) for the View's DropZone. */
+/** @cfg {Boolean} copy Causes drag operations to copy nodes rather than move. */
+/** @cfg {Boolean} allowCopy Causes ctrl/drag operations to copy nodes rather than move. */
+
+ sortDir: 'ASC',
+
+ isFormField: true,
+
+ classRe: /class=(['"])(.*)\1/,
+
+ tagRe: /(<\w*)(.*?>)/,
+
+ reset: Ext.emptyFn,
+
+ clearInvalid: Ext.form.Field.prototype.clearInvalid,
+
+ msgTarget: 'qtip',
+
+ afterRender: function() {
+ Ext.ux.DDView.superclass.afterRender.call(this);
+ if (this.dragGroup) {
+ this.setDraggable(this.dragGroup.split(","));
+ }
+ if (this.dropGroup) {
+ this.setDroppable(this.dropGroup.split(","));
+ }
+ if (this.deletable) {
+ this.setDeletable();
+ }
+ this.isDirtyFlag = false;
+ this.addEvents(
+ "drop"
+ );
+ },
+
+ validate: function() {
+ return true;
+ },
+
+ destroy: function() {
+ this.purgeListeners();
+ this.getEl().removeAllListeners();
+ this.getEl().remove();
+ if (this.dragZone) {
+ if (this.dragZone.destroy) {
+ this.dragZone.destroy();
+ }
+ }
+ if (this.dropZone) {
+ if (this.dropZone.destroy) {
+ this.dropZone.destroy();
+ }
+ }
+ },
+
+/** Allows this class to be an Ext.form.Field so it can be found using {@link Ext.form.BasicForm#findField}. */
+ getName: function() {
+ return this.name;
+ },
+
+/** Loads the View from a JSON string representing the Records to put into the Store. */
+ setValue: function(v) {
+ if (!this.store) {
+ throw "DDView.setValue(). DDView must be constructed with a valid Store";
+ }
+ var data = {};
+ data[this.store.reader.meta.root] = v ? [].concat(v) : [];
+ this.store.proxy = new Ext.data.MemoryProxy(data);
+ this.store.load();
+ },
+
+/** @return {String} a parenthesised list of the ids of the Records in the View. */
+ getValue: function() {
+ var result = '(';
+ this.store.each(function(rec) {
+ result += rec.id + ',';
+ });
+ return result.substr(0, result.length - 1) + ')';
+ },
+
+ getIds: function() {
+ var i = 0, result = new Array(this.store.getCount());
+ this.store.each(function(rec) {
+ result[i++] = rec.id;
+ });
+ return result;
+ },
+
+ isDirty: function() {
+ return this.isDirtyFlag;
+ },
+
+/**
+ * Part of the Ext.dd.DropZone interface. If no target node is found, the
+ * whole Element becomes the target, and this causes the drop gesture to append.
+ */
+ getTargetFromEvent : function(e) {
+ var target = e.getTarget();
+ while ((target !== null) && (target.parentNode != this.el.dom)) {
+ target = target.parentNode;
+ }
+ if (!target) {
+ target = this.el.dom.lastChild || this.el.dom;
+ }
+ return target;
+ },
+
+/**
+ * Create the drag data which consists of an object which has the property "ddel" as
+ * the drag proxy element.
+ */
+ getDragData : function(e) {
+ var target = this.findItemFromChild(e.getTarget());
+ if(target) {
+ if (!this.isSelected(target)) {
+ delete this.ignoreNextClick;
+ this.onItemClick(target, this.indexOf(target), e);
+ this.ignoreNextClick = true;
+ }
+ var dragData = {
+ sourceView: this,
+ viewNodes: [],
+ records: [],
+ copy: this.copy || (this.allowCopy && e.ctrlKey)
+ };
+ if (this.getSelectionCount() == 1) {
+ var i = this.getSelectedIndexes()[0];
+ var n = this.getNode(i);
+ dragData.viewNodes.push(dragData.ddel = n);
+ dragData.records.push(this.store.getAt(i));
+ dragData.repairXY = Ext.fly(n).getXY();
+ } else {
+ dragData.ddel = document.createElement('div');
+ dragData.ddel.className = 'multi-proxy';
+ this.collectSelection(dragData);
+ }
+ return dragData;
+ }
+ return false;
+ },
+
+// override the default repairXY.
+ getRepairXY : function(e){
+ return this.dragData.repairXY;
+ },
+
+/** Put the selections into the records and viewNodes Arrays. */
+ collectSelection: function(data) {
+ data.repairXY = Ext.fly(this.getSelectedNodes()[0]).getXY();
+ if (this.preserveSelectionOrder === true) {
+ Ext.each(this.getSelectedIndexes(), function(i) {
+ var n = this.getNode(i);
+ var dragNode = n.cloneNode(true);
+ dragNode.id = Ext.id();
+ data.ddel.appendChild(dragNode);
+ data.records.push(this.store.getAt(i));
+ data.viewNodes.push(n);
+ }, this);
+ } else {
+ var i = 0;
+ this.store.each(function(rec){
+ if (this.isSelected(i)) {
+ var n = this.getNode(i);
+ var dragNode = n.cloneNode(true);
+ dragNode.id = Ext.id();
+ data.ddel.appendChild(dragNode);
+ data.records.push(this.store.getAt(i));
+ data.viewNodes.push(n);
+ }
+ i++;
+ }, this);
+ }
+ },
+
+/** Specify to which ddGroup items in this DDView may be dragged. */
+ setDraggable: function(ddGroup) {
+ if (ddGroup instanceof Array) {
+ Ext.each(ddGroup, this.setDraggable, this);
+ return;
+ }
+ if (this.dragZone) {
+ this.dragZone.addToGroup(ddGroup);
+ } else {
+ this.dragZone = new Ext.dd.DragZone(this.getEl(), {
+ containerScroll: true,
+ ddGroup: ddGroup
+ });
+// Draggability implies selection. DragZone's mousedown selects the element.
+ if (!this.multiSelect) { this.singleSelect = true; }
+
+// Wire the DragZone's handlers up to methods in *this*
+ this.dragZone.getDragData = this.getDragData.createDelegate(this);
+ this.dragZone.getRepairXY = this.getRepairXY;
+ this.dragZone.onEndDrag = this.onEndDrag;
+ }
+ },
+
+/** Specify from which ddGroup this DDView accepts drops. */
+ setDroppable: function(ddGroup) {
+ if (ddGroup instanceof Array) {
+ Ext.each(ddGroup, this.setDroppable, this);
+ return;
+ }
+ if (this.dropZone) {
+ this.dropZone.addToGroup(ddGroup);
+ } else {
+ this.dropZone = new Ext.dd.DropZone(this.getEl(), {
+ owningView: this,
+ containerScroll: true,
+ ddGroup: ddGroup
+ });
+
+// Wire the DropZone's handlers up to methods in *this*
+ this.dropZone.getTargetFromEvent = this.getTargetFromEvent.createDelegate(this);
+ this.dropZone.onNodeEnter = this.onNodeEnter.createDelegate(this);
+ this.dropZone.onNodeOver = this.onNodeOver.createDelegate(this);
+ this.dropZone.onNodeOut = this.onNodeOut.createDelegate(this);
+ this.dropZone.onNodeDrop = this.onNodeDrop.createDelegate(this);
+ }
+ },
+
+/** Decide whether to drop above or below a View node. */
+ getDropPoint : function(e, n, dd){
+ if (n == this.el.dom) { return "above"; }
+ var t = Ext.lib.Dom.getY(n), b = t + n.offsetHeight;
+ var c = t + (b - t) / 2;
+ var y = Ext.lib.Event.getPageY(e);
+ if(y <= c) {
+ return "above";
+ }else{
+ return "below";
+ }
+ },
+
+ isValidDropPoint: function(pt, n, data) {
+ if (!data.viewNodes || (data.viewNodes.length != 1)) {
+ return true;
+ }
+ var d = data.viewNodes[0];
+ if (d == n) {
+ return false;
+ }
+ if ((pt == "below") && (n.nextSibling == d)) {
+ return false;
+ }
+ if ((pt == "above") && (n.previousSibling == d)) {
+ return false;
+ }
+ return true;
+ },
+
+ onNodeEnter : function(n, dd, e, data){
+ if (this.highlightColor && (data.sourceView != this)) {
+ this.el.highlight(this.highlightColor);
+ }
+ return false;
+ },
+
+ onNodeOver : function(n, dd, e, data){
+ var dragElClass = this.dropNotAllowed;
+ var pt = this.getDropPoint(e, n, dd);
+ if (this.isValidDropPoint(pt, n, data)) {
+ if (this.appendOnly || this.sortField) {
+ return "x-tree-drop-ok-below";
+ }
+
+// set the insert point style on the target node
+ if (pt) {
+ var targetElClass;
+ if (pt == "above"){
+ dragElClass = n.previousSibling ? "x-tree-drop-ok-between" : "x-tree-drop-ok-above";
+ targetElClass = "x-view-drag-insert-above";
+ } else {
+ dragElClass = n.nextSibling ? "x-tree-drop-ok-between" : "x-tree-drop-ok-below";
+ targetElClass = "x-view-drag-insert-below";
+ }
+ if (this.lastInsertClass != targetElClass){
+ Ext.fly(n).replaceClass(this.lastInsertClass, targetElClass);
+ this.lastInsertClass = targetElClass;
+ }
+ }
+ }
+ return dragElClass;
+ },
+
+ onNodeOut : function(n, dd, e, data){
+ this.removeDropIndicators(n);
+ },
+
+ onNodeDrop : function(n, dd, e, data){
+ if (this.fireEvent("drop", this, n, dd, e, data) === false) {
+ return false;
+ }
+ var pt = this.getDropPoint(e, n, dd);
+ var insertAt = (this.appendOnly || (n == this.el.dom)) ? this.store.getCount() : n.viewIndex;
+ if (pt == "below") {
+ insertAt++;
+ }
+
+// Validate if dragging within a DDView
+ if (data.sourceView == this) {
+// If the first element to be inserted below is the target node, remove it
+ if (pt == "below") {
+ if (data.viewNodes[0] == n) {
+ data.viewNodes.shift();
+ }
+ } else { // If the last element to be inserted above is the target node, remove it
+ if (data.viewNodes[data.viewNodes.length - 1] == n) {
+ data.viewNodes.pop();
+ }
+ }
+
+// Nothing to drop...
+ if (!data.viewNodes.length) {
+ return false;
+ }
+
+// If we are moving DOWN, then because a store.remove() takes place first,
+// the insertAt must be decremented.
+ if (insertAt > this.store.indexOf(data.records[0])) {
+ insertAt--;
+ }
+ }
+
+// Dragging from a Tree. Use the Tree's recordFromNode function.
+ if (data.node instanceof Ext.tree.TreeNode) {
+ var r = data.node.getOwnerTree().recordFromNode(data.node);
+ if (r) {
+ data.records = [ r ];
+ }
+ }
+
+ if (!data.records) {
+ alert("Programming problem. Drag data contained no Records");
+ return false;
+ }
+
+ for (var i = 0; i < data.records.length; i++) {
+ var r = data.records[i];
+ var dup = this.store.getById(r.id);
+ if (dup && (dd != this.dragZone)) {
+ if(!this.allowDup && !this.allowTrash){
+ Ext.fly(this.getNode(this.store.indexOf(dup))).frame("red", 1);
+ return true
+ }
+ var x=new Ext.data.Record();
+ r.id=x.id;
+ delete x;
+ }
+ if (data.copy) {
+ this.store.insert(insertAt++, r.copy());
+ } else {
+ if (data.sourceView) {
+ data.sourceView.isDirtyFlag = true;
+ data.sourceView.store.remove(r);
+ }
+ if(!this.allowTrash)this.store.insert(insertAt++, r);
+ }
+ if(this.sortField){
+ this.store.sort(this.sortField, this.sortDir);
+ }
+ this.isDirtyFlag = true;
+ }
+ this.dragZone.cachedTarget = null;
+ return true;
+ },
+
+// Ensure the multi proxy is removed
+ onEndDrag: function(data, e) {
+ var d = Ext.get(this.dragData.ddel);
+ if (d && d.hasClass("multi-proxy")) {
+ d.remove();
+ //delete this.dragData.ddel;
+ }
+ },
+
+ removeDropIndicators : function(n){
+ if(n){
+ Ext.fly(n).removeClass([
+ "x-view-drag-insert-above",
+ "x-view-drag-insert-left",
+ "x-view-drag-insert-right",
+ "x-view-drag-insert-below"]);
+ this.lastInsertClass = "_noclass";
+ }
+ },
+
+/**
+ * Utility method. Add a delete option to the DDView's context menu.
+ * @param {String} imageUrl The URL of the "delete" icon image.
+ */
+ setDeletable: function(imageUrl) {
+ if (!this.singleSelect && !this.multiSelect) {
+ this.singleSelect = true;
+ }
+ var c = this.getContextMenu();
+ this.contextMenu.on("itemclick", function(item) {
+ switch (item.id) {
+ case "delete":
+ this.remove(this.getSelectedIndexes());
+ break;
+ }
+ }, this);
+ this.contextMenu.add({
+ icon: imageUrl || AU.resolveUrl("/images/delete.gif"),
+ id: "delete",
+ text: AU.getMessage("deleteItem")
+ });
+ },
+
+/** Return the context menu for this DDView. */
+ getContextMenu: function() {
+ if (!this.contextMenu) {
+// Create the View's context menu
+ this.contextMenu = new Ext.menu.Menu({
+ id: this.id + "-contextmenu"
+ });
+ this.el.on("contextmenu", this.showContextMenu, this);
+ }
+ return this.contextMenu;
+ },
+
+ disableContextMenu: function() {
+ if (this.contextMenu) {
+ this.el.un("contextmenu", this.showContextMenu, this);
+ }
+ },
+
+ showContextMenu: function(e, item) {
+ item = this.findItemFromChild(e.getTarget());
+ if (item) {
+ e.stopEvent();
+ this.select(this.getNode(item), this.multiSelect && e.ctrlKey, true);
+ this.contextMenu.showAt(e.getXY());
+ }
+ },
+
+/**
+ * Remove {@link Ext.data.Record}s at the specified indices.
+ * @param {Array/Number} selectedIndices The index (or Array of indices) of Records to remove.
+ */
+ remove: function(selectedIndices) {
+ selectedIndices = [].concat(selectedIndices);
+ for (var i = 0; i < selectedIndices.length; i++) {
+ var rec = this.store.getAt(selectedIndices[i]);
+ this.store.remove(rec);
+ }
+ },
+
+/**
+ * Double click fires the event, but also, if this is draggable, and there is only one other
+ * related DropZone that is in another DDView, it drops the selected node on that DDView.
+ */
+ onDblClick : function(e){
+ var item = this.findItemFromChild(e.getTarget());
+ if(item){
+ if (this.fireEvent("dblclick", this, this.indexOf(item), item, e) === false) {
+ return false;
+ }
+ if (this.dragGroup) {
+ var targets = Ext.dd.DragDropMgr.getRelated(this.dragZone, true);
+
+// Remove instances of this View's DropZone
+ while (targets.contains(this.dropZone)) {
+ targets.remove(this.dropZone);
+ }
+
+// If there's only one other DropZone, and it is owned by a DDView, then drop it in
+ if ((targets.length == 1) && (targets[0].owningView)) {
+ this.dragZone.cachedTarget = null;
+ var el = Ext.get(targets[0].getEl());
+ var box = el.getBox(true);
+ targets[0].onNodeDrop(el.dom, {
+ target: el.dom,
+ xy: [box.x, box.y + box.height - 1]
+ }, null, this.getDragData(e));
+ }
+ }
+ }
+ },
+
+ onItemClick : function(item, index, e){
+// The DragZone's mousedown->getDragData already handled selection
+ if (this.ignoreNextClick) {
+ delete this.ignoreNextClick;
+ return;
+ }
+
+ if(this.fireEvent("beforeclick", this, index, item, e) === false){
+ return false;
+ }
+ if(this.multiSelect || this.singleSelect){
+ if(this.multiSelect && e.shiftKey && this.lastSelection){
+ this.select(this.getNodes(this.indexOf(this.lastSelection), index), false);
+ } else if (this.isSelected(item) && e.ctrlKey) {
+ this.deselect(item);
+ }else{
+ this.deselect(item);
+ this.select(item, this.multiSelect && e.ctrlKey);
+ this.lastSelection = item;
+ }
+ e.preventDefault();
+ }
+ return true;
+ }
+});
diff --git a/src/webui/static/multiselect/multiselect.js b/src/webui/static/multiselect/multiselect.js
new file mode 100644
index 00000000..9cc18329
--- /dev/null
+++ b/src/webui/static/multiselect/multiselect.js
@@ -0,0 +1,528 @@
+//version 3.0
+
+Ext.ux.Multiselect = Ext.extend(Ext.form.Field, {
+ store:null,
+ dataFields:[],
+ data:[],
+ width:100,
+ height:100,
+ displayField:0,
+ valueField:1,
+ allowBlank:true,
+ minLength:0,
+ maxLength:Number.MAX_VALUE,
+ blankText:Ext.form.TextField.prototype.blankText,
+ minLengthText:'Minimum {0} item(s) required',
+ maxLengthText:'Maximum {0} item(s) allowed',
+ copy:false,
+ allowDup:false,
+ allowTrash:false,
+ legend:null,
+ focusClass:undefined,
+ delimiter:',',
+ view:null,
+ dragGroup:null,
+ dropGroup:null,
+ tbar:null,
+ appendOnly:false,
+ sortField:null,
+ sortDir:'ASC',
+ defaultAutoCreate : {tag: "div"},
+
+ initComponent: function(){
+ Ext.ux.Multiselect.superclass.initComponent.call(this);
+ this.addEvents({
+ 'dblclick' : true,
+ 'click' : true,
+ 'change' : true,
+ 'drop' : true
+ });
+ },
+ onRender: function(ct, position){
+ var fs, cls, tpl;
+ Ext.ux.Multiselect.superclass.onRender.call(this, ct, position);
+
+ cls = 'ux-mselect';
+
+ fs = new Ext.form.FieldSet({
+ renderTo:this.el,
+ title:this.legend,
+ height:this.height,
+ width:this.width,
+ style:"padding:1px;",
+ tbar:this.tbar
+ });
+ if(!this.legend)fs.el.down('.'+fs.headerCls).remove();
+ fs.body.addClass(cls);
+
+ tpl = '