skos_browser/commit

Move basic functionality from Amalgame

authorMichiel Hildebrand
Wed Nov 7 17:46:42 2012 +0100
committerMichiel Hildebrand
Wed Nov 7 17:46:42 2012 +0100
commitbe10e9e003b5ecfcf3a003553bd3121e27cd90b9
treeedecb3505525b93016a7793022526ed4aa4efcd9
parent922a922c743061e669c96626604387414b95bda8
Diff style: patch stat
diff --git a/api/skos_concepts.pl b/api/skos_concepts.pl
new file mode 100644
index 0000000..7202f4a
--- /dev/null
+++ b/api/skos_concepts.pl
@@ -0,0 +1,355 @@
+:- module(skos_concepts,
+	  []).
+
+:- use_module(library(http/http_dispatch)).
+:- use_module(library(http/http_parameters)).
+:- use_module(library(http/html_write)).
+:- use_module(library(http/http_json)).
+:- use_module(library(http/json)).
+:- use_module(library(http/json_convert)).
+:- use_module(library(semweb/rdf_db)).
+:- use_module(library(semweb/rdf_label)).
+
+
+:- http_handler(skosapi(conceptschemes), http_conceptschemes, []).
+:- http_handler(skosapi(concepts), http_concepts, []).
+
+%%	http_concept_schemes(+Request)
+%
+%       API handler to fetch concept schemes
+
+http_conceptschemes(Request) :-
+	http_parameters(Request,
+			[ offset(Offset,
+				[integer, default(0),
+				 description('Start of the results returned')]),
+			  limit(Limit,
+				[integer, default(20),
+				 description('maximum number of results returned')]),
+			  query(Query,
+				[optional(true),
+				 description('keyword query to filter the results by')])
+			]),
+	ConceptScheme = concept(Concept, Label, true),
+	findall(ConceptScheme, concept_scheme(Query, Concept, Label), Cs),
+	length(Cs, Total),
+	list_offset(Cs, Offset, OffsetResults),
+	list_limit(OffsetResults, Limit, LimitResults, _),
+	prolog_to_json(LimitResults, JSONResults),
+	reply_json(json([offset=Offset,
+			 limit=Limit,
+			 totalNumberOfResults=Total,
+			 results=JSONResults])).
+
+:- json_object
+	concept(id:atom, label:atom, hasNext:boolean).
+
+concept_scheme(Query, C, Label) :-
+	var(Query),
+	!,
+	rdf(C, rdf:type, skos:'ConceptScheme'),
+	rdf_display_label(C, Label).
+concept_scheme(Query, C, Label) :-
+	rdf(C, rdf:type, skos:'ConceptScheme'),
+	once(label_prefix(Query, C, Lit)),
+	literal_text(Lit, Label).
+
+
+%%	http_concepts(+Request)
+%
+%       API handler to fetch top concepts
+
+http_concepts(Request) :-
+	http_parameters(Request,
+			[ parent(Parent,
+				 [description('Concept or concept scheme from which we request the concepts')]),
+			  type(Type,
+			       [oneof(topconcept,inscheme,child,descendant,related),
+				default(inscheme),
+				description('Method to determine the concepts')]),
+			  offset(Offset,
+				[integer, default(0),
+				 description('Start of the results returned')]),
+			  limit(Limit,
+				[integer, default(20),
+				 description('maximum number of results returned')]),
+			  query(Query,
+				[optional(true),
+				 description('keyword query to filter the results by')])
+			]),
+	C = concept(Concept, Label, HasNarrower),
+	findall(C, concept(Type, Parent, Query, Concept, Label, HasNarrower), Cs0),
+	sort(Cs0, Cs),
+	term_sort_by_arg(Cs, 2, Sorted),
+	length(Sorted, Total),
+	list_offset(Sorted, Offset, OffsetResults),
+	list_limit(OffsetResults, Limit, LimitResults, _),
+	prolog_to_json(LimitResults, JSONResults),
+	reply_json(json([parent=Parent,
+			 offset=Offset,
+			 limit=Limit,
+			 totalNumberOfResults=Total,
+			 results=JSONResults])).
+
+concept(Type, Parent, Query, Concept, Label, HasNarrower) :-
+	var(Query),
+	!,
+	concept_(Type, Parent, Concept),
+	has_narrower(Concept, HasNarrower),
+	rdf_display_label(Concept, Label).
+concept(Type, Parent, Query, Concept, Label, HasNarrower) :-
+	concept_(Type, Parent, Concept),
+	once(label_prefix(Query, Concept, Lit)),
+	literal_text(Lit, Label),
+	has_narrower(Concept, HasNarrower).
+
+concept_(inscheme, ConceptScheme, Concept) :- !,
+	inscheme(ConceptScheme, Concept).
+concept_(topconcept, ConceptScheme, Concept) :- !,
+	top_concept(ConceptScheme, Concept).
+concept_(child, Parent, Concept) :-
+	narrower_concept(Parent, Concept).
+concept_(descendant, Parent, Concept) :-
+	descendant(Parent, Concept).
+concept_(related, Parent, Concept) :-
+	related_concept(Parent, Concept).
+
+%%	inscheme(+ConceptScheme, -Concept)
+%
+%	True if Concept is contained in a skos:ConceptScheme by
+%	skos:inScheme.
+
+inscheme(ConceptScheme, Concept) :-
+	rdf(Concept, skos:inScheme, ConceptScheme).
+
+%%	top_concept(+ConceptScheme, -Concept)
+%
+%	True if Concept is a skos:hasTopConcept of ConceptScheme, or
+%	inversely by skos:topConceptOf
+
+top_concept(ConceptScheme, Concept) :-
+	rdf(ConceptScheme, skos:hasTopConcept, Concept).
+top_concept(ConceptScheme, Concept) :-
+	rdf(Concept, skos:topConceptOf, ConceptScheme),
+	\+ rdf(ConceptScheme, skos:hasTopConcept, Concept).
+
+%%	narrower_concept(+Concept, -Narrower)
+%
+%	True if Narrower is related to Concept by skos:narrower or
+%	inversely by skos:broader.
+
+narrower_concept(Concept, Narrower) :-
+	rdf_has(Narrower, skos:broader, Concept).
+narrower_concept(Concept, Narrower) :-
+	rdf_has(Concept, skos:narrower, Narrower),
+	\+ rdf_has(Narrower, skos:narrower, Concept).
+
+%%	descendant(+Concept, -Descendant)
+%
+%	Descendant is a child of Concept or recursively of its children
+
+descendant(Concept, Descendant) :-
+	narrower_concept(Concept, Narrower),
+	(   Descendant = Narrower
+	;   descendant(Narrower, Descendant)
+	).
+
+%%	related_concept(+Concept, -Related)
+%
+%	True if Related is related to Concept by skos:related.
+
+related_concept(Concept, Related) :-
+	rdf_has(Concept, skos:related, Related).
+related_concept(Concept, Related) :-
+	rdf_has(Related, skos:related, Concept),
+	\+ rdf_has(Concept, skos:related, Related).
+
+%%	has_narrower(+Concept, -Boolean)
+%
+%	Boolean is true when concept has a skos:narrower concept.
+
+has_narrower(Concept, true) :-
+	rdf_has(Concept, skos:narrower, _),
+	!.
+has_narrower(Concept, true) :-
+	rdf_has(_, skos:broader, Concept),
+	!.
+has_narrower(_, false).
+
+
+%%	http_concept_info(+Request)
+%
+%       API handler to fetch info about a URI.
+%
+%       @TBD support for language tags
+
+http_concept_info(Request) :-
+	http_parameters(Request,
+			[  concept(C,
+			       [description('Concept to request info about')])
+			]),
+	rdf_display_label(C, Label),
+	skos_description(C, Desc),
+	skos_alt_labels(C, AltLabels0),
+	delete(AltLabels0, Label, AltLabels),
+	skos_related_concepts(C, Related),
+	voc_get_computed_props(C, Amalgame),
+	format('Content-type: text/html~n~n'),
+	phrase(html(\html_info_snippet(C, Label, Desc, AltLabels, Related, Amalgame)), HTML),
+	print_html(HTML).
+
+skos_description(C, Desc) :-
+	(   rdf_has(C, skos:scopeNote, Lit)
+	->  literal_text(Lit, Desc)
+	;   Desc = ''
+	).
+skos_alt_labels(C, AltLabels) :-
+	findall(AL, ( rdf_has(C, skos:altLabel, Lit),
+		      literal_text(Lit, AL)
+		    ),
+		AltLabels0),
+	sort(AltLabels0, AltLabels).
+skos_related_concepts(C, Related) :-
+	Concept = concept(R, Label),
+	findall(Concept, ( skos_related(C, R),
+			   rdf_display_label(R, Label)
+		    ),
+		Related).
+
+skos_related(C, R) :-
+	rdf_has(C, skos:related, R).
+skos_related(C, R) :-
+	rdf_has(R, skos:related, C),
+	\+ rdf_has(C, skos:related, R).
+
+html_info_snippet(URI, Label, Desc, AltLabels, Related, AmalGame) -->
+	{ truncate_atom(Desc, 80, ShortDesc)
+	},
+	html(div(class(infobox),
+		 [ h3([\resource_link(URI, Label),
+		       \html_label_list(AltLabels)
+		      ]),
+		   div(class(uri), URI),
+		   div(style('float:left'),
+		       [ div([class(desc), title(Desc)], ShortDesc),
+			 \html_related_list(Related)
+		       ]),
+		   div(class(amalgame),
+		       \html_amalgame_props(AmalGame))
+		 ])).
+
+html_label_list([]) --> !.
+html_label_list(Ls) -->
+	html(span(class(altlabels),
+		  [ ' (',
+		    \html_label_list_(Ls),
+		    ')'
+		  ])).
+
+html_label_list_([L]) --> !,
+	html(span(class(label), L)).
+html_label_list_([L|Ls]) -->
+	html(span(class(label), [L,', '])),
+	html_label_list_(Ls).
+
+html_related_list([]) --> !.
+html_related_list(Cs) -->
+	html(div(class(related),
+		 [ 'related: ',
+		   \html_concept_list(Cs)
+		 ])).
+
+html_concept_list([concept(URI, Label)]) --> !,
+	resource_link(URI, Label).
+html_concept_list([concept(URI, Label)|Cs]) -->
+	html([\resource_link(URI, Label), ', ']),
+	html_concept_list(Cs).
+
+html_amalgame_props([]) --> !.
+html_amalgame_props(Props) -->
+	html(table(\amalgame_props(Props))).
+
+amalgame_props([]) --> !.
+amalgame_props([Term|Ts]) -->
+	{ Term =.. [Prop, Value],
+	  literal_text(Value, Txt)
+	},
+	html(tr([td(Prop), td(Txt)])),
+	amalgame_props(Ts).
+
+resource_link(URI, Label) -->
+	{ www_form_encode(URI, EncURI)
+	},
+	html(a(href(location_by_id(list_resource)+'?r='+EncURI), Label)).
+
+		 /*******************************
+		 *	     UTILILIES          *
+		 *******************************/
+
+%%	terms_sort_by_arg(+ListOfTerms, +N, -SortedTerms)
+%
+%	Sorts ListOfTerms by the nth argument of each term.
+
+term_sort_by_arg(List, Arg, Sorted) :-
+	maplist(arg_key(Arg), List, Pairs),
+	keysort(Pairs, Sorted0),
+	pairs_values(Sorted0, Sorted).
+
+arg_key(N, Term, Key-Term) :-
+	arg(N, Term, Key).
+
+%%	list_offset(+List, +N, -SmallerList)
+%
+%	SmallerList starts at the nth element of List.
+
+list_offset(L, N, []) :-
+	length(L, Length),
+	Length < N,
+	!.
+list_offset(L, N, L1) :-
+	list_offset_(L, N, L1).
+
+list_offset_(L, 0, L) :- !.
+list_offset_([_|T], N, Rest) :-
+	N1 is N-1,
+	list_offset_(T, N1, Rest).
+
+%%	list_limit(+List, +N, -SmallerList, -Rest)
+%
+%	SmallerList ends at the nth element of List.
+
+list_limit(L, N, L, []) :-
+	length(L, Length),
+	Length < N,
+	!.
+list_limit(L, N, L1, Rest) :-
+	list_limit_(L, N, L1, Rest).
+
+list_limit_(Rest, 0, [], Rest) :- !.
+list_limit_([H|T], N, [H|T1], Rest) :-
+	N1 is N-1,
+	list_limit_(T, N1, T1, Rest).
+
+%%	label_prefix(+Query, -R, -Lit)
+%
+%	True if Query matches a literal value of R.
+
+label_prefix(Query, R, Lit) :-
+	rdf_has(R, rdfs:label, literal(prefix(Query), Lit)).
+label_prefix(Query, R, Lit) :-
+	rdf_has(O, rdf:value, literal(prefix(Query), Lit)),
+	rdf_has(R, rdfs:label, O).
+
+
+%%	reply_jsonp(+JSON, +Callback)
+%
+%	Output an html script node, where JSON is embedded in a
+%	javascript funtion.
+
+reply_jsonp(JSON, Callback) :-
+	with_output_to(string(JSONString),
+		       json_write(current_output, JSON, [])),
+	format('Content-type: text/javascript~n~n'),
+	phrase(html([Callback,'(',JSONString,')']), HTML),
+	print_html(HTML).
+
diff --git a/applications/skos_browser.pl b/applications/skos_browser.pl
new file mode 100644
index 0000000..66a1143
--- /dev/null
+++ b/applications/skos_browser.pl
@@ -0,0 +1,98 @@
+:- module(skos_browser, []).
+
+:- use_module(library(http/http_dispatch)).
+:- use_module(library(http/html_write)).
+:- use_module(library(http/http_path)).
+:- use_module(library(http/html_head)).
+:- use_module(library(yui3_beta)).
+
+:- http_handler(skosbrowser(.), http_skos_browser, []).
+
+
+%%	http_skos_browser(+Request)
+%
+%	HTTP handler for web page that instantiates a JavaScript
+%	column browser widget. It uses the skos apis defined in
+%	@skos_concepts
+
+http_skos_browser(_Request) :-
+	reply_html_page(cliopatria(default),
+			[ title(['SKOS vocabulary browser'])
+			],
+			[  \html_requires(css('columnbrowser.css')),
+			   h2('SKOS vocabulary browser'),
+			   div([class('yui-skin-sam'), id(browser)], []),
+			   script(type('text/javascript'),
+				  [ \yui_script
+				  ])
+			]).
+
+%%	yui_script(+Graph)
+%
+%	Emit YUI object.
+
+yui_script -->
+	{ findall(M-C, js_module(M,C), Modules),
+	  pairs_keys(Modules, Includes),
+	  DS = datasource,
+	  Browser = browser
+	},
+	yui3([json([modules(json(Modules))])],
+	     [datasource|Includes],
+	     [ \skos_api_datasource(DS),
+	       \skos_browser(DS, Browser)
+	     ]).
+
+skos_api_datasource(DS) -->
+	yui3_new(DS,
+		 'Y.DataSource.IO',
+		 {source:''}
+		 ),
+	yui3_plug(DS,
+		  'Y.Plugin.DataSourceJSONSchema',
+		  {schema: {resultListLocator: results,
+			    resultFields: [id, label, hasNext, matches, scheme],
+			    metaFields: {totalNumberOfResults:totalNumberOfResults}
+			   }
+		  }),
+	yui3_plug(DS,
+		  'Y.Plugin.DataSourceCache',
+		  {max:10}).
+
+skos_browser(DS, Browser) -->
+	{ http_location_by_id(http_conceptschemes, ConceptSchemes),
+	  http_location_by_id(http_concepts, Concepts)
+	},
+	yui3_new(Browser,
+		 'Y.mazzle.ColumnBrowser',
+		 {datasource: symbol(DS),
+		  maxNumberItems: 100,
+		  columns: [
+			    {   request: ConceptSchemes,
+				label: 'concept scheme'
+			    },
+			    {   request: Concepts,
+				label: concept,
+				params: {type:'topconcept'},
+				options: [
+					  {value:inscheme, label:'all concepts'},
+					  {value:topconcept, selected:true, label: 'top concepts'}
+					 ]
+			    },
+			    {   request: Concepts,
+				params: {type:child},
+				options: [],
+				repeat: true
+			    }
+			   ]
+		 }),
+	yui3_render(Browser, id(browser)).
+
+js_module(resourcelist, json([fullpath(Path),
+			      requires([node,event,widget])
+			     ])) :-
+    http_absolute_location(js('resourcelist.js'), Path, []).
+js_module(columnbrowser, json([fullpath(Path),
+			     requires([node,event,widget,resourcelist])
+			    ])) :-
+    http_absolute_location(js('columnbrowser.js'), Path, []).
diff --git a/config-available/skos_browser.pl b/config-available/skos_browser.pl
index ae6bbd8..d71a446 100644
--- a/config-available/skos_browser.pl
+++ b/config-available/skos_browser.pl
@@ -1,5 +1,15 @@
 :- module(conf_skos_browser, []).
 
+:- use_module(library(semweb/rdf_library)).
+:- use_module(library(skos_schema)).
+
 /** <module> SKOS vocabulary browser
 */
 
+http:location(skosbrowser, cliopatria(skos/browser), []).
+http:location(skosapi, cliopatria(skos/api), []).
+
+:- use_module(api(skos_concepts)).
+:- use_module(applications(skos_browser)).
+
+:- rdf_attach_library(vocs).
diff --git a/web/css/columnbrowser.css b/web/css/columnbrowser.css
new file mode 100644
index 0000000..24eba36
--- /dev/null
+++ b/web/css/columnbrowser.css
@@ -0,0 +1,81 @@
+.yui3-columnbrowser {
+	border-width: 1px;
+	border-style: solid;
+	border-color: #CCC;
+	
+	font-size: 12px;
+}
+.yui3-columnbrowser .hidden {
+	display: none;
+}
+.yui3-columnbrowser .loading {
+	padding-top: 20px;
+	background: url(loading.gif) no-repeat center center
+}
+.yui3-columnbrowser .columns-box {
+	height: 300px;
+	overflow-x: auto;
+	overflow-y: hidden;
+}
+.yui3-columnbrowser .columns-box.noscroll {
+	overflow-x: hidden;
+}
+.yui3-columnbrowser .columns {
+	height: 100%;
+}
+
+.yui3-columnbrowser .columns .yui3-resourcelist {
+	float: left;
+	height: 100%;
+	overflow-x: auto;
+	overflow-y: scroll;
+}
+.yui3-columnbrowser .yui3-resourcelist-content ul {
+	padding: 2px 0;
+	margin: 0;
+}
+.yui3-columnbrowser .resourcelist-item {
+	padding: 3px 5px 2px;
+	cursor: pointer;
+	overflow: hidden;
+}
+.yui3-columnbrowser .resourcelist-item .more {
+	float: right;
+}
+.yui3-columnbrowser .resourcelist-item.selected {
+	background-color: #DDD;
+}
+.yui3-columnbrowser .yui3-resourcelist.selected .resourcelist-item.selected {
+	background-color: #3875D7;
+	color: #FFF;
+}
+.yui3-columnbrowser .pagination {
+	border-color:#CCCCCC;
+	border-style:solid;
+	border-width:1px 0 0;
+	text-align:center;
+}
+.yui3-columnbrowser .pagination .page-label {
+	padding: 0 10px;
+}
+.yui3-columnbrowser .pagination a.disabled {
+	color: #CCC;
+}
+
+.yui3-columnbrowser .search,
+.yui3-columnbrowser select {
+	border-color:#CCC;
+	border-style:solid;
+	border-width:0 0 1px;
+}
+.yui3-columnbrowser .search {
+	padding: 0 2px
+}
+.yui3-columnbrowser select {
+	width: 100%;
+}
+.yui3-columnbrowser .search input {
+	width: 100%;
+	border: none;
+	background: url("search_bg_enabled.png") no-repeat right 60% #FFFFFF;
+}
\ No newline at end of file
diff --git a/web/css/loading.gif b/web/css/loading.gif
new file mode 100644
index 0000000..d324d7e
Binary files /dev/null and b/web/css/loading.gif differ
diff --git a/web/css/search_bg_enabled.png b/web/css/search_bg_enabled.png
new file mode 100644
index 0000000..717691e
Binary files /dev/null and b/web/css/search_bg_enabled.png differ
diff --git a/web/js/columnbrowser.js b/web/js/columnbrowser.js
new file mode 100644
index 0000000..986cc59
--- /dev/null
+++ b/web/js/columnbrowser.js
@@ -0,0 +1,249 @@
+YUI.add('columnbrowser', function(Y) {
+
+	var Lang = Y.Lang,
+		Widget = Y.Widget,
+		Node = Y.Node;
+
+	Widget.ColumnBrowser = ColumnBrowser;
+	var NS = Y.namespace('mazzle'); 
+	NS.ColumnBrowser = ColumnBrowser;
+	
+	/* ColumnBrowser class constructor */
+	function ColumnBrowser(config) {
+		ColumnBrowser.superclass.constructor.apply(this, arguments);
+	}
+
+	/* 
+	 * Required NAME static field, to identify the Widget class and 
+	 * used as an event prefix, to generate class names etc. (set to the 
+	 * class name in camel case). 
+	 */
+	ColumnBrowser.NAME = "columnbrowser";
+
+	/*
+	 * The attribute configuration for the ColumnBrowser widget. Attributes can be
+	 * defined with default values, get/set functions and validator functions
+	 * as with any other class extending Base.
+	 */
+	ColumnBrowser.ATTRS = {
+		datasource: {
+			value: null
+		},
+		columns: {
+			value: null
+		},
+		columnWidth: {
+			value: 200,
+			validator: Lang.isNumber
+		},
+		maxNumberItems: {
+			value: 100,
+			validator: Lang.isNumber
+		},
+		minQueryLength: {
+			value: 2,
+			validator: Lang.isNumber
+		},
+		queryDelay: {
+			value: 0.3,
+			validator: Lang.isNumber
+		},
+		selected: {
+			value: null
+		}
+	};
+
+	/* ColumnBrowser extends the base Widget class */
+	Y.extend(ColumnBrowser, Widget, {
+
+		initializer: function(config) {
+			// internal variables
+			this._activeIndex = null;
+			this._selectedIndex = null;
+			
+			// internal references to nodes
+			this.columnsBox = null;
+			this.columnsNode = null;
+			
+			this.publish("itemSelect", {});
+		},
+
+		destructor : function() {
+			// purge itemSelect, optionSelect, offSetSelect, valueChange?
+		},
+
+		renderUI : function() {
+			this.columnsBox = this.get("contentBox")
+				.appendChild(Node.create('<div class="columns-box"></div>'));
+			this.columnsNode = this.columnsBox	
+				.appendChild(Node.create('<div class="columns"></div>'));
+		},
+
+		bindUI : function() {
+		},
+
+		syncUI : function() {
+			if(this._activeIndex===0||this._activeIndex) {
+				var columns = this.get("columns"),
+					activeIndex = this._activeIndex,
+					selectedIndex = this._selectedIndex,
+					selectedItem = this._selectedItem;
+			
+				// update the status of the columns
+				// to "selected" or "hidden"
+				for (var i=0; i < columns.length; i++) {
+					var list = columns[i].list;
+					if(list) {
+						if(i==selectedIndex) {
+							list.get("boundingBox").addClass("selected");
+						} else {
+							list.get("boundingBox").removeClass("selected");
+						}
+						if(i<=activeIndex) {
+							list.set("visible", true);
+						} else {
+							list.set("visible", false);
+						}
+					}
+				}
+
+				// update the active column
+				this._updateContentSize();
+				//columns[activeIndex].list._node.scrollIntoView();
+			} else {
+				this._activeIndex = 0;
+				this._updateColumn(0);
+			}
+		},
+		
+		/** 
+		* Public functions to fetch ids and labels from result items
+		*/
+		itemId : function(item) {
+			var id = item.id ? item.id : item;
+			return id;
+		},
+		itemLabel : function(item) {
+			var label = item.label ? item.label : item;
+			return label;
+		},
+		
+		/**
+		* Handles the selection of a resource list item.
+		* Fires the itemSelect event
+		* 
+		* @private
+		* @param listItem {Object} the list element node
+		* @param resource {Object} the selected resource
+		**/
+		_itemSelect : function(listItem, oItem, index) {
+			var columns = this.get("columns"),
+				next = index+1;
+			
+			this.set("selected", oItem);
+			this._selectedItem = oItem;
+			this._selectedIndex = index;
+			
+			if(columns[next]||columns[index].repeat) {
+				if(oItem.hasNext) {	
+					this._updateColumn(next, this.itemId(oItem));
+				} else {
+					this.syncUI();
+				}
+			}
+			
+			this.fire("itemSelect", oItem, index, listItem);
+		},
+		_setActiveColumn : function(e, index) {
+			this._selectedIndex = index>=1 ? index-1 : null;
+			this._activeIndex = index;
+			this.syncUI();
+		},
+			
+		/**
+		* Creates a new column	based on Y.mazzle.ResourceList
+		*
+		* @private
+		**/ 
+		_updateColumn : function(index, parent) {	
+			if(!this.get("columns")[index]) {
+				this.get("columns")[index] = {};
+			}
+
+			var column = this.get("columns")[index];
+ 			if(!column.list) {
+				column.list = this._createColumnList(index);
+			}
+			if(parent) {
+				column.list.set("params.parent", parent);
+			}
+			column.list.updateContent();
+		},
+		
+		_createColumnList : function(index) {
+			var columns = this.get("columns"),
+				previous = columns[index-1]||{},
+				column = columns[index];
+	
+			var cfg = {
+				width: this.get("columnWidth"),
+				maxNumberItems: this.get("maxNumberItems"),
+				minQueryLength: this.get("minQueryLength"),
+				queryDelay: this.get("queryDelay")
+			};
+
+			column.repeat = column.repeat||previous.repeat;
+			column.label = column.label || (column.repeat&&previous.label);
+				
+			// column properties are defined or inherited from previous column			
+			cfg.datasource = this.get("datasource");
+			cfg.request = column.request||(column.repeat&&previous.request);	
+			cfg.params = column.params||(column.repeat&&previous.params);
+			cfg.options = column.options||(column.repeat&&previous.options);
+			
+			var list = new Y.mazzle.ResourceList(cfg);
+			if(column.formatter) {
+				list.formatItem = column.formatter
+			}
+			list.render(this.columnsNode);
+			
+			
+			list.on("itemClick", this._itemSelect, this, index);
+			list.on("beforeContentUpdate", this._setActiveColumn, this, index);
+			return list;
+		},
+									
+		/**
+		 * Handles resizing column content by
+		 * setting the size of this.colomnsNode to the width of the combined columns
+		 **/
+		_updateContentSize : function() {
+			var columns = this.get("columns"),
+				content = this.columnsNode,
+				width = 0;
+			
+			for (var i=0; i < columns.length; i++) {
+				var columnList = columns[i].list;
+				if(columnList&&columnList.get("visible")) {
+					width += columnList.get("boundingBox").get("offsetWidth");
+				}
+			}
+			
+			content.setStyle("width", width+"px");
+			content.get("parentNode").removeClass("noscroll");
+			this._updateColumnsSize();
+		},
+		
+		_updateColumnsSize : function() {
+			var columns = this.get("columns"),
+				height = this.columnsNode.get("offsetHeight");
+			for (var i=0; i < columns.length; i++) {
+				if(columns[i].list) {
+					columns[i].list.set("height", height+"px");
+				}
+			}
+		}
+				
+	}); 
+
+}, 'gallery-2010.03.02-18' ,{requires:['node','event','widget','resourcelist','value-change']});
\ No newline at end of file
diff --git a/web/js/resourcelist.js b/web/js/resourcelist.js
new file mode 100644
index 0000000..ba6c217
--- /dev/null
+++ b/web/js/resourcelist.js
@@ -0,0 +1,437 @@
+YUI.add('resourcelist', function(Y) {
+
+	var Lang = Y.Lang,
+		Widget = Y.Widget,
+		Node = Y.Node;
+
+	Widget.ResourceList = ResourceList;
+	var NS = Y.namespace('mazzle'); 
+	NS.ResourceList = ResourceList;
+	
+	/* ResourceList class constructor */
+	function ResourceList(config) {
+		ResourceList.superclass.constructor.apply(this, arguments);
+	}
+
+	/* 
+	 * Required NAME static field, to identify the Widget class and 
+	 * used as an event prefix, to generate class names etc. (set to the 
+	 * class name in camel case). 
+	 */
+	ResourceList.NAME = "resourcelist";
+
+	/*
+	 * The attribute configuration for the ResourceList widget. Attributes can be
+	 * defined with default values, get/set functions and validator functions
+	 * as with any other class extending Base.
+	 */
+	ResourceList.ATTRS = {
+		query: {
+			value: null
+		},
+		minQueryLength: {
+			value: 2,
+			validator: Lang.isNumber
+		},
+		queryDelay: {
+			value: 0.3,
+			validator: Lang.isNumber
+		},
+		page: {
+			value: 0,
+			validator: Lang.isNumber
+		},
+		maxNumberItems: {
+			value: 100,
+			validator : Lang.isNumber
+		},
+		totalNumberOfResults: {
+			value: 0,
+			validator: Lang.isNumber
+		},
+		resources: {
+			value: [],
+			validator : Lang.isArray
+		},
+		selected: {
+			value: [],
+			validator : Lang.isArray
+		},
+		options: {
+			value: [],
+			validator: Lang.isArray
+		},
+		loading: {
+			value: false,
+			validator: Lang.isBoolean
+		},
+		datasource: {
+			value: null
+		},
+		request: {
+			value: ""
+		},
+		params: {
+			value: {}
+		}
+	};
+
+	/* Static constants used to define the markup templates used to create ResourceList DOM elements */
+	ResourceList.LIST_CLASS = ResourceList.NAME+'-list';
+	ResourceList.ITEM_CLASS = ResourceList.NAME+'-item';
+	ResourceList.LIST_TEMPLATE = '<ul class="'+ResourceList.LIST_CLASS+'"></ul>';
+	ResourceList.ITEM_TEMPLATE = '<li class="'+ResourceList.ITEM_CLASS+'"></li>';
+
+	/* ResourceList extends the base Widget class */
+	Y.extend(ResourceList, Widget, {
+
+		initializer: function(config) {
+			this._nDelayID = -1;
+			this._listItems = [];
+			this._list = null;
+			this._load = null;
+			this._pagination = null;
+			
+			this.publish("itemClick", {});
+			this.publish("beforeContentUpdate", {});
+			this.publish("afterContentUpdate", {});
+			this.publish("optionSelect", {});
+		},
+
+		destructor : function() {
+		},
+
+		renderUI : function() {
+			this._renderSearch();
+			this._renderOptions();
+			this._renderLoad();
+			this._renderList();
+		},
+
+		bindUI : function() {
+			Y.delegate("click", this._itemSelect, this._list, "li", this);
+		},
+
+		syncUI : function() {
+			
+			if(this.get("loading")) {
+				// replace content with loading message
+				this._list.addClass("hidden");
+				this._load.removeClass("hidden");
+			} 
+			else {
+				// update the column content
+				this.populateList();
+				// hide loading message and show content
+				this._load.addClass("hidden");
+				this._list.removeClass("hidden");
+			}		
+		},
+
+		_renderList : function() {
+			var content = this.get("contentBox"),
+				nListLength = this.get("maxNumberItems");
+			
+			// add ul
+			var list = content.one("."+ResourceList.LIST_CLASS);
+			if(!list) {
+				list = content.appendChild(Node.create(ResourceList.LIST_TEMPLATE));
+			}
+			// add list items
+			var listItems = [];
+			for (var i=0; i < nListLength; i++) {
+				var listItem = list.appendChild(Node.create(ResourceList.ITEM_TEMPLATE));
+				listItem.setStyle("display", "block");
+				listItem._node._nItemIndex = i;
+				listItems.push({el:listItem});
+			}	
+			this._list = list;
+			this._listItems = listItems;
+		},
+		
+		/**
+		* Creates a HTML select list with options provided in the 
+		* configuration for columns[index].
+		* An eventhandler is added to the HTML select element which is
+		* handled by _optionSelect
+		*
+		* @private
+		**/
+		_renderOptions : function() {
+			var options = this.get("options");
+
+			if(options&&options.length>0) {
+				var optionsNode = this.get("contentBox")
+						.appendChild(Node.create('<select class="options"></select>'));
+
+				for (var i=0; i < options.length; i++) {
+					var option = options[i],
+						value = option.value,
+						label = option.label ? option.label : value,	
+						selected = option.selected ? 'selected' : '';
+					optionsNode.insert('<option value="'+value+'" '+selected+'>'+label+'</option>');
+				}
+				optionsNode.on("change", this._optionSelect, this);
+			}
+		},
+		
+		_renderSearch : function() {
+			var search = this.get("contentBox")
+				.appendChild(Node.create('<div class="search"></div>'))
+				.appendChild(Node.create('<input type="text" />'));
+			search.on("valuechange", this._valueChangeHandler, this);	
+		},
+		
+		/**
+		* Creates pagination in a column.
+		* An eventhandler is added to the prev and next buttons which is
+		* handled by _offsetSelect
+		* The pagination is stored in column._pagination, such that it is created
+		* only once.
+		* If pagination already exists we simply show it.
+		*
+		* @private
+		* @param length {Integer} the number of resources
+		**/
+		_renderPagination : function() {
+			var length = this.get("totalNumberOfResults"),
+				limit = this.get("maxNumberItems"),
+				start = this.get("page")*limit,
+				end = start+Math.min(limit, length);
+				
+			if(length>limit) {	
+				if(!this._pagination) { // create the pagination HTML
+					var pagination = this.get("contentBox")
+						.appendChild(Node.create('<div class="pagination"></div>'));
+					pagination.appendChild(Node.create('<a href="javascript:{}" class="page-prev">prev</a>'))
+						.on("click", this._offsetSelect, this, -1);
+					pagination.insert('<span class="page-label"></span>');
+					pagination.appendChild(Node.create('<a href="javascript:{}" class="page-next">next</a>'))
+						.on("click", this._offsetSelect, this, 1);
+					this._pagination = pagination;
+				} else { // or show it
+					this._pagination.removeClass("hidden");
+				}
+			
+				// now disable the inactive buttons
+				if(length<limit) { 
+					Y.one(".page-next").addClass("disabled");
+					Y.one(".page-prev").removeClass("disabled");
+				} else if (start===0) { 
+					Y.one(".page-prev").addClass("disabled", true); 
+					Y.one(".page-next").removeClass("disabled");
+				} else {
+					Y.one(".page-next").removeClass("disabled");
+					Y.one(".page-prev").removeClass("disabled");
+				}
+				// and add the right labels
+				Y.one(".page-label").set("innerHTML", start+' -- '+end); 
+			} 
+			else if(this._pagination) {
+				this._pagination.addClass("hidden");
+			}
+		},
+		
+		_renderLoad : function() {
+			this._load = this.get("contentBox").appendChild(
+				Y.Node.create('<div class="hidden loading"></div>'));
+		},
+		
+		populateList : function() {
+			var listItems = this._listItems,
+				resources = this.get("resources"),
+				numberItems = Math.min(this.get("maxNumberItems"), resources.length);
+
+			this.clearSelection();
+			// add resources
+			var i = 0;	
+			for (i; i < numberItems; i++) {
+				var oResource = resources[i],
+					HTML = this.formatItem(oResource),
+					oItem = listItems[i],
+					elItem = oItem.el;
+
+				oItem.resource = oResource;
+				elItem.set("innerHTML", HTML);
+				elItem.setStyle("display", "block");
+			}
+			
+			// clear remaining elements
+			for (i; i < listItems.length; i++) {
+				var oItem = listItems[i],
+					elItem = oItem.el;
+				if(elItem.getStyle("display")=="none") {
+					return;
+				} else {
+					elItem.innerHTML = [];
+					elItem.setStyle("display", "none");
+					oItem.resource = null;
+				}	
+			}
+			this.setSelection();
+			this._renderPagination();
+		},
+		
+		setSelection : function() {
+			var selected = this.get("selected");
+			for (var i=0; i < selected.length; i++) {
+				selected[i].addClass("selected");
+			}
+		},
+		clearSelection : function() {
+			var selected = this.get("selected");
+			for (var i=0; i < selected.length; i++) {
+				selected[i].removeClass("selected");
+			}
+			this.set("selected", []);
+		},
+		
+		_itemSelect : function(e) {
+			var listItems = this._listItems,
+				itemNode = e.currentTarget,
+				oResource = listItems[itemNode.get("_nItemIndex")].resource;
+			
+			// @tbd add support for multiple selections
+			this.clearSelection();
+			this.set("selected", [itemNode]);
+			this.setSelection();
+			
+			itemNode.addClass("selected");
+			this.fire("itemClick", itemNode, oResource);
+		},
+		
+		/**
+		* Handles the selection of a column option.
+		* Fires the optionSelect event
+		* 
+		* @private
+		* @param e {Object} the event object
+		**/
+		_optionSelect : function(e) {
+			var optionValue = e.currentTarget.get("value");
+			this.set("page", 0);
+			this.set("params.type", optionValue);
+			this.fire("optionSelect", optionValue);
+			this.updateContent();
+		},
+		
+		/**
+		* Handles the selection of a pagination action
+		* Fires the offsetSelect event
+		* 
+		* @private
+		* @param e {Object} the event object
+		* @param direction {1 or -1} indicator for next (1) or prev (-1)
+		**/		
+		_offsetSelect : function(e, direction) {
+			this.set("page", this.get("page")+direction);
+			this.fire("offsetSelect", direction);
+			this.updateContent();
+		},
+		
+		/**
+		 * The handler that listens to valueChange events and decides whether or not
+		 * to kick off a new query.
+		 *
+		 * @param {Object} The event object
+		 * @private
+		 **/
+		_valueChangeHandler : function(e) {
+			var oSelf = this,
+				query = e.newVal;
+
+			// Clear previous timeout to prevent old searches to push through
+		    if(oSelf._nDelayID != -1) {
+		        clearTimeout(oSelf._nDelayID);
+		    }
+			
+			this.set("page", 0);
+			if (!query || query.length < this.get("minQueryLength")) {
+				this.set("query", "");
+				this.updateContent();
+			}
+			else {
+	    		// Set a timeout to prevent too many search requests
+				var oSelf = this;
+	    		oSelf._nDelayID = setTimeout(function(){
+					oSelf.set("query", query);
+	            	oSelf.updateContent();
+	        	}, this.get("queryDelay")*1000);
+			}
+		},
+		
+		/**
+		* Fetches data by doing a
+		* request on the datasource.
+		*
+		* @private
+		**/
+		updateContent : function() {
+			this.fire("beforeContentUpdate");
+			var request = this.get("request"),
+				params = this.get("params");
+
+			this._nDelayID = -1; // reset search query delay
+			this.set("loading", true);
+			this.syncUI();
+				
+			// the request configuration attribute consist of params in
+			// the column definition and the current status of the column 	
+			params.limit = this.get("maxNumberItems");
+			params.offset = this.get("page")*this.get("maxNumberItems");
+			params.query = this.get("query");
+			
+			request = Lang.isFunction(request) 
+				? request.call(this, params) 
+				: request+"?"+this._requestParams(params);
+
+			var oSelf = this;
+			this.get("datasource").sendRequest({
+				request:request,
+				callback: {
+					success: function(o) {
+						var results = o.response.results,
+							total = o.response.meta
+								? o.response.meta.totalNumberOfResults 
+								: results.length;
+						oSelf.set("totalNumberOfResults", total)
+						oSelf.set("loading", false);
+						oSelf.set("resources", results);	
+						oSelf.syncUI();
+						oSelf.fire("afterContentUpdate");
+					},
+					failure: function(o) {
+						oSelf.set("totalNumberOfResults", 0)
+						oSelf.set("loading", false);
+						oSelf.set("resources", []);	
+						oSelf.syncUI();	
+						oSelf.fire("afterContentUpdate");
+					}	
+				}
+			});
+		},
+		
+		_requestParams : function(params) {
+			var paramString = "";
+			for(var key in params) {
+				if(params[key]) {
+					paramString += key+"="+encodeURIComponent(params[key])+"&";
+				}
+			}
+			return paramString;
+		},
+		
+		formatItem : function(oResource) {
+		        var label = oResource["label"],
+		            uri   = oResource["id"],
+		            value = (label&&!Y.Lang.isObject(label)) ? label : uri;
+			var HTML = "";
+			if(oResource.hasNext) { HTML += "<div class='more'>&gt;</div>"; }
+			HTML += "<div class='resourcelist-item-value' title='"+uri+"'>"+value+"</div>";
+			return HTML;
+		}
+
+		
+	});
+	  
+}, 'gallery-2010.03.02-18' ,{requires:['node','event','widget']});
\ No newline at end of file