diff --git a/.npmignore b/.npmignore
index a9168a5..a510091 100644
--- a/.npmignore
+++ b/.npmignore
@@ -3,10 +3,10 @@
.gitignore
Gruntfile.js
server.js
-lib/*
node_modules/*
public/js/search.js
+public/js/README.md
+public/js/admin.js
+public/js/external/*
public/stylesheets/*
-routes/*
views/*
-test/*
diff --git a/Gruntfile.js b/Gruntfile.js
index 1c6ac56..92eff2a 100755
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -4,16 +4,22 @@ module.exports = function( grunt ) {
csslint: {
files: [
- "public/**/*.css"
+ "public/gallery/**/*.css",
+ "public/stylesheets/search.css"
]
},
jshint: {
+ options: {
+ es5: true,
+ newcap: false
+ },
files: [
"Gruntfile.js",
"server.js",
"lib/**/*.js",
- "public/**/*.js",
- "routes/**/*.js"
+ "public/js/*.js",
+ "routes/**/*.js",
+ "test/**/*.js"
]
}
});
diff --git a/README.md b/README.md
index fb8edf4..f77db0c 100755
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Execute `npm install` in the application directory:
Copy and edit your .env file. -- This should never be committed to the repo. Ensure that you fill in the ALLOWED_USERS variable.
```
-cp .env.sample .env
+cp env.sample .env
```
#### Running the Services
@@ -73,21 +73,30 @@ Right now there is a small node app in `test/test-make-client.js` that will requ
POST
/api/make
Create Make
-
If Post Data is a valid Make, it creates one and returns it with the _id and __v populated.
+
+ If post data contains a valid Make, it creates one and returns it with the _id.
+ Post Data should be a JSON object specifying the id of the authenticated webmaker creating the Make
+ { "maker": "username", make: { ... } }
+
Yes
PUT
/api/make/:id
Update a Make
-
The Make must already exist and the __v must be the same as the current version on the server. This is an implementation of optimistic locking.
+
The Make must already exist. This is an implementation of optimistic locking.
+ Post Data should be a JSON object specifying the id of the authenticated webmaker updating the Make and a flag indicating if the user has admin priveliges.
+ { "maker": "username", make: { ... } }
+
Yes
DELETE
/api/make/:id
Deletes a Make
-
The effect is that of a delete operation, though the Make is actually only marked as deleted using the deletedAt timestamp.
+
The effect is that of a delete operation, though the Make is actually only marked as deleted using the deletedAt timestamp.
+ Post Data should be a JSON object specifying the id of the authenticated webmaker deleting the Make and a flag indicating if the user has admin priveliges.
+ { "maker": "username" }
Yes
@@ -100,27 +109,27 @@ Right now there is a small node app in `test/test-make-client.js` that will requ
-### Example Usage
+### Consuming the API
```
jQuery.ajax({
type: "POST",
url: "/api/make",
data: {
- "url": "http://thimble.webmadecontent.org/abcd.html",
- "contentType": "text/html",
- "title": "Animal something-or-other",
- "locale": "en_us",
- "tags": ["awesome"],
- "privateTags": ["webmaker.org:project", "skill:css"],
- "description": "This handy HTML template makes it easy to quickly create your own text and image mashup, then publish it for sharing via Facebook, Tumblr or any web page. Your 15 seconds of internet fame await!",
- "author": "swex@mozilla.com",
- "contentAuthor": "swex@mozilla.com",
- "remixedFrom": null,
- "published": true
+ "user": "webmaker@host.com",
+ "make": {
+ "url": "http://thimble.webmadecontent.org/abcd.html",
+ "contentType": "application/x-thimble",
+ "title": "Animal something-or-other",
+ "locale": "en_us",
+ "tags": [ "awesome", "#css", "thimble.webmaker.org:project" ],
+ "description": "This handy HTML template makes it easy to quickly create your own text and image mashup, then publish it for sharing via Facebook, Tumblr or any web page. Your 15 seconds of internet fame await!",
+ "author": "swex@mozilla.com",
+ "remixedFrom": null
+ }
},
success: function(data, textStatus, jqXHR){
- console.log("Post resposne:");
+ console.log("Post response:");
console.dir(data);
console.log(textStatus);
console.dir(jqXHR);
@@ -130,6 +139,8 @@ Right now there is a small node app in `test/test-make-client.js` that will requ
}
});
```
+A client library has been written to aid in the consumption of this API.
+Documentation can be found [here](public/js/README.md)
### Searching Test Ground
diff --git a/env.sample b/env.sample
index 97f63a3..0f191b5 100644
--- a/env.sample
+++ b/env.sample
@@ -1,25 +1,28 @@
# A Secret used to sign Session cookies.
-SESSION_SECRET=I wish the people who clean my office at night were invited to our company Christmas party.
+export SESSION_SECRET='I wish the people who clean my office at night were invited to our company Christmas party.'
# Port to listen on
-PORT=5000
+export PORT=5000
# development or production
-export NODE_ENV="development"
+export NODE_ENV='development'
# URL of the Mongodb instance
-MONGO_URL=mongodb://localhost/makeapi
+export MONGO_URL='mongodb://localhost/makeapi'
# Where the server is running. Used for test data generating script
-HOST='localhost'
+export HOST='localhost'
# Host and port for your Elastic search cluster
-ELASTIC_SEARCH_URL='http://localhost:9200'
+export ELASTIC_SEARCH_URL='http://localhost:9200'
# A List of allowed users for write operations to the database
# List in this format: "user:pass,user2:pass"
# You shouldn't use this username/password combo yourself
-ALLOWED_USERS="testuser:password"
+export ALLOWED_USERS='testuser:password'
+
+# Persona
+export AUDIENCE="http://webmaker.mofostaging.net"
# statsd metrics collection. If the following are left empty, no stats
# will be collected or sent to a server. Only STATSD_HOST and STATSD_PORT
@@ -28,3 +31,7 @@ ALLOWED_USERS="testuser:password"
export STATSD_HOST=
export STATSD_PORT=
export STATSD_PREFIX=
+
+# This is used to check if a user is an administrator (include user/password)
+# in URL. Don't use this username/password combo
+export LOGIN_SERVER_URL_WITH_AUTH='http://loginuser:loginpassword@localhost:3000'
diff --git a/lib/api.js b/lib/api.js
new file mode 100644
index 0000000..2f1a6e8
--- /dev/null
+++ b/lib/api.js
@@ -0,0 +1,4 @@
+module.exports = {
+ makeAPI: require( "../public/js/make-api.js" ),
+ fakeAPI: require( "../test/fake/FakeAPI.js" )
+};
diff --git a/lib/middleware.js b/lib/middleware.js
index 619fb78..32592a4 100644
--- a/lib/middleware.js
+++ b/lib/middleware.js
@@ -2,23 +2,84 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
-module.exports = function( env ) {
-
- var userList = env.get( "ALLOWED_USERS" ),
- qs = require( "querystring" );
-
- userList = qs.parse( userList, ",", ":" );
+module.exports = function( loginApi, env ) {
+ var qs = require( "querystring" ),
+ userList = qs.parse( env.get( "ALLOWED_USERS" ), ",", ":" ),
+ tags = require( "./tags" )();
return {
+ // Use with express.basicAuth middleware
authenticateUser: function( user, pass ) {
- for ( var username in userList ) {
- if ( userList.hasOwnProperty( username ) ) {
- if ( user === username && pass === userList[ username ] ) {
- return true;
- }
+ var found = false;
+ Object.keys( userList ).forEach( function( username ) {
+ if ( user === username && pass === userList[ username ] ) {
+ found = true;
}
+ });
+ return found;
+ },
+ prefixAuth: function( req, res, next ) {
+
+ if ( typeof req.body.make === "string" ) {
+ req.body.make = qs.parse( req.body.make );
}
- return false;
+ var makerID = req.body.maker,
+ makeTags = req.body.make.tags,
+ appTags = req.body.make.appTags;
+
+ makeTags = typeof makeTags === "string" ? [makeTags] : makeTags;
+ appTags = typeof appTags === "string" ? [appTags] : appTags;
+
+ loginApi.isAdmin( makerID, function( err, isAdmin ) {
+ if ( err ) {
+ return res.json( 500, { error: err } );
+ }
+
+ var options = {
+ maker: makerID,
+ isAdmin: isAdmin
+ },
+ validTags = [];
+
+ if ( makeTags ) {
+ validTags = tags.validateTags( makeTags, options );
+ }
+
+ if ( appTags ) {
+ validTags = validTags.concat( tags.validateApplicationTags( appTags, req.user ) );
+ }
+
+ req.body.make.tags = validTags;
+
+ next();
+ });
+ },
+ adminAuth: function( req, res, next ) {
+ var email = req.session ? req.session.email : "";
+ if ( email ) {
+ loginApi.isAdmin( email, function( err, isAdmin ) {
+ if ( err || !isAdmin ) {
+ return res.json( 403, { reason: "forbidden" } );
+ }
+ next();
+ });
+ } else {
+ res.redirect( 302, "/login" );
+ }
+ },
+ verifyPersonaLogin: function( err, req, res, email ) {
+ if ( err ) {
+ return res.json({ status: "failure", reason: err });
+ }
+ loginApi.isAdmin( email, function( error, isAdmin ) {
+ var out;
+ if ( error || !isAdmin ) {
+ out = { status: "failure", reason: error || "you are not authorised to view this page." };
+ } else {
+ out = { status: "okay", email: email };
+ }
+ res.json(out);
+ });
}
};
};
diff --git a/lib/models/make.js b/lib/models/make.js
index ba63ef5..ef5d58c 100644
--- a/lib/models/make.js
+++ b/lib/models/make.js
@@ -6,6 +6,7 @@ module.exports = function( environment, mongoInstance ) {
var mongoosastic = require( "mongoosastic" ),
validate = require( "mongoose-validator" ).validate,
+ deferred = require( "deferred" ),
env = environment,
url = require( "url" ),
mongoose = mongoInstance,
@@ -47,13 +48,11 @@ module.exports = function( environment, mongoInstance ) {
title: {
type: String,
es_indexed: true,
- required: true,
- es_index: "not_analyzed"
+ required: true
},
description: {
type: String,
- es_indexed: true,
- es_index: "not_analyzed"
+ es_indexed: true
},
thumbnail: {
type: String,
@@ -71,8 +70,7 @@ module.exports = function( environment, mongoInstance ) {
required: true,
validate: validate( "isEmail" ),
es_indexed: true,
- es_index: "not_analyzed",
- select: false
+ es_index: "not_analyzed"
},
published: {
type: Boolean,
@@ -86,11 +84,10 @@ module.exports = function( environment, mongoInstance ) {
es_type: "String"
},
remixedFrom: {
- type: Number,
+ type: String,
"default": null,
es_indexed: true,
- es_index: "not_analyzed",
- es_type: "long"
+ es_index: "not_analyzed"
},
createdAt: Timestamp,
updatedAt: Timestamp,
@@ -102,6 +99,12 @@ module.exports = function( environment, mongoInstance ) {
}
});
+ schema.set( "toJSON", { virtuals: true } );
+
+ schema.virtual( "id" ).get(function() {
+ return this._id;
+ });
+
// Hooks
schema.pre( "save", function ( next ) {
this.updatedAt = Date.now();
@@ -122,9 +125,9 @@ module.exports = function( environment, mongoInstance ) {
}
});
- Make.publicFields = [ "url", "contentType", "locale", "locales",
- "title", "description", "author",
- "published", "tags", "thumbnail", "email", "remixedFrom" ];
-
+ Make.publicFields = [ "url", "contentType", "locale",
+ "title", "description", "author", "published", "tags",
+ "thumbnail", "remixedFrom" ];
+
return Make;
};
diff --git a/lib/tags.js b/lib/tags.js
new file mode 100644
index 0000000..e45f510
--- /dev/null
+++ b/lib/tags.js
@@ -0,0 +1,67 @@
+module.exports = function() {
+
+ // Application Tags are "webmaker.org:foo", which means two
+ // strings, joined with a ':', and the first string does not
+ // contain an '@'
+ var appTagRegex = /(^[^@]+)\:[^:]+/,
+
+ // User Tags are "some@something.com:foo", which means two
+ // strings, joined with a ':', and the first string contains
+ // an email address (i.e., an '@').
+ userTagRegex = /^([^@]+@[^@]+)\:[^:]+/,
+
+ // Raw Tags are "foo" or "#fooBar", which means one string
+ // which does not include a colon.
+ rawTagRegex = /^[^:]+$/,
+
+ // Trim any whitespace around tags
+ trimWhitespace = function( tags ) {
+ return tags.map(function( val ) {
+ return val.trim();
+ });
+ };
+
+ return {
+ validateTags: function( tags, options ) {
+
+ var user;
+
+ tags = trimWhitespace( tags );
+
+ return tags.filter(function( val ){
+
+ // allow if user is an admin, or val is a raw tag
+ if ( options.isAdmin || rawTagRegex.test( val ) ) {
+ return true;
+ }
+
+ user = userTagRegex.exec( val );
+
+ // Allow if val is a user tag, and user is logged in
+ if ( user && user[ 1 ] === options.maker ) {
+ return true;
+ }
+
+ return false;
+ });
+ },
+ validateApplicationTags: function( tags, application ) {
+
+ var appTag;
+
+ tags = trimWhitespace( tags );
+
+ return tags.filter(function( val ) {
+ appTag = appTagRegex.exec( val );
+
+ // Allow if is application tag, and the application tag matches the
+ // username of the app making the request
+ if ( appTag && appTag[ 1 ] === application ) {
+ return true;
+ }
+
+ return false;
+ });
+ }
+ };
+};
diff --git a/package.json b/package.json
index 12a7fd0..49b0a8c 100755
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "makeapi",
- "version": "0.0.11",
+ "version": "0.1.12",
"description": "MakeAPI for Webmaker",
- "main": "public/js/make-api.js",
+ "main": "./lib/api.js",
"scripts": {
"test": "grunt"
},
@@ -27,17 +27,20 @@
],
"license": "MPL-2.0",
"dependencies": {
+ "deferred": "0.6.4",
"express": "~3.0.6",
+ "express-persona": "0.0.8",
+ "Faker": "0.5.8",
"habitat": "0.4.1",
"mongoose": "~3.5.2",
"mongoose-validator": "~0.2.1",
"mongoosastic": "0.0.11",
"newrelic": "0.9.20",
"node-statsd": "0.0.7",
- "nunjucks": "~0.1.8a",
+ "nunjucks": "0.1.8a",
"request": "2.20.0",
"querystring": "0.2.0",
- "Faker": "0.5.8"
+ "webmaker-loginapi": "0.1.2"
},
"devDependencies": {
"grunt": "~0.4.1",
diff --git a/public/images/calendar.gif b/public/images/calendar.gif
new file mode 100644
index 0000000..90fd2e1
Binary files /dev/null and b/public/images/calendar.gif differ
diff --git a/public/js/README.md b/public/js/README.md
new file mode 100644
index 0000000..7b9d485
--- /dev/null
+++ b/public/js/README.md
@@ -0,0 +1,35 @@
+#Make API Client Library#
+
+
+##API Reference##
+TO-DO
+
+##Tagging##
+
+There are 3 types of tags that can be applied to makes. Each tag requires specific levels of authentication, which will be described below.
+
+**Raw Tags**
+
+Raw tags can be applied by any user, and can be prefixed by a '#'.
+
+I.E.
+\#Awesome
+WebLiteracy
+very-cool
+
+**User Tags**
+
+User tags are the only prefix that a regular webmaker user can apply to a tag. The separator for a prefixed tag is a colon ':'
+
+I.E.
+webmaker@domain.com:favourite
+webmaker2@domain.com:myawesomelist
+
+**Application tags**
+
+These tags are reserved for trusted apps (with the exception that an admin webmaker can add them)
+
+I.E.
+webmaker.org:featured
+popcorn.webmaker.org:project
+thimble.webmaker.org:tutorial
diff --git a/public/js/admin.js b/public/js/admin.js
new file mode 100644
index 0000000..5010cb3
--- /dev/null
+++ b/public/js/admin.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+$(function() {
+
+ var Slick = window.Slick,
+
+ formatters = {
+ date: function( row, cell, val ) {
+ var newDate;
+
+ try {
+ newDate = val ? new Date( val ) : "N/A";
+ } catch( e ) {
+ newDate = Date.now();
+ // Alert User to Error? - need error infrastructure
+ }
+ return newDate;
+ },
+ tags: function( row, cell, val ) {
+ return Array.isArray( val ) ? val.join( "," ) : val;
+ }
+ };
+
+ var COLUMNS = [
+ { id: "url", name: "Url", field: "url",
+ editor: Slick.Editors.Text
+ },
+ { id: "contentType", name: "Content Type", field: "contentType",
+ editor: Slick.Editors.Text
+ },
+ { id: "locale", name: "Locale", field: "locale",
+ editor: Slick.Editors.Text
+ },
+ { id: "title", name: "Title", field: "title",
+ editor: Slick.Editors.Text
+ },
+ { id: "description", name: "Description", field: "description",
+ editor: Slick.Editors.Text
+ },
+ { id: "thumbnail", name: "Thumbnail Url", field: "thumbnail",
+ editor: Slick.Editors.Text
+ },
+ { id: "author", name: "Author", field: "author" },
+ { id: "tags", name: "Tags", field: "tags",
+ formatter: formatters.tags,
+ editor: Slick.Editors.Text
+ },
+ { id: "remixedFrom", name: "Remixed From", field: "remixedFrom" },
+ { id: "createdAt", name: "Created At", field: "createdAt",
+ formatter: formatters.date,
+ editor: Slick.Editors.Date
+ },
+ { id: "updatedAt", name: "Updated At", field: "updatedAt",
+ formatter: formatters.date,
+ editor: Slick.Editors.Date
+ },
+ { id: "deletedAt", name: "Deleted At", field: "deletedAt",
+ formatter: formatters.date,
+ editor: Slick.Editors.Date
+ }
+ ];
+
+ var options = {
+ autoEdit: false,
+ editable: true,
+ autoHeight: true,
+ enableTextSelectionOnCells: true,
+ defaultColumnWidth: 150,
+ topPanelHeight: 200
+ },
+ make = window.Make({
+ apiURL: "/admin"
+ }),
+ tagSearchInput = $( "#search-tag" ),
+ searchBtn = $( "#search" ),
+ gridArea = $( ".data-table-area" ),
+ identity = $( "#identity" ).text(),
+ grid,
+ data;
+
+ function updateMake( e, data ) {
+ var make = data.item;
+
+ make.tags = Array.isArray( make.tags )? make.tags : make.tags.split( "," );
+ make.email = identity;
+
+ make.update( identity, function( err, data ) {
+ if ( err ) {
+ console.log( err );
+ return;
+ }
+ // Better Success/Failure notification
+ });
+ }
+
+ function createGrid( err, data ) {
+ if ( err ) {
+ console.log( err );
+ // need better error handling
+ return;
+ }
+ grid = new Slick.Grid( gridArea, data, COLUMNS, options );
+ grid.onCellChange.subscribe( updateMake );
+ }
+
+ function doSearch() {
+ make
+ .tags( tagSearchInput.val().split( "," ) )
+ .then( createGrid );
+ }
+
+ searchBtn.click( doSearch );
+ tagSearchInput.keypress(function( e ) {
+ if ( e.which === 13 ) {
+ e.preventDefault();
+ e.stopPropagation();
+ doSearch();
+ tagSearchInput.blur();
+ }
+ });
+
+ // SSO
+
+ var logout = $( "#logout" );
+
+ logout.click(function(){
+ navigator.idSSO.logout();
+ });
+
+ navigator.idSSO.watch({
+ onlogin: function() {},
+ onlogout: function() {
+ var request = new XMLHttpRequest();
+
+ request.open( "POST", "/persona/logout", true );
+ request.addEventListener( "loadend", function() {
+ window.location.replace( "./login" );
+ }, false);
+ request.send();
+ }
+ });
+});
diff --git a/public/js/external/jquery.event.drag-2.2.js b/public/js/external/jquery.event.drag-2.2.js
new file mode 100644
index 0000000..0f5ce18
--- /dev/null
+++ b/public/js/external/jquery.event.drag-2.2.js
@@ -0,0 +1,10 @@
+/*!
+ * jquery.event.drag - v 2.2
+ * Copyright (c) 2010 Three Dub Media - http://threedubmedia.com
+ * Open Source MIT License - http://threedubmedia.com/code/license
+ */
+// Created: 2008-06-04
+// Updated: 2012-05-21
+// REQUIRES: jquery 1.7.x
+
+(function(a){a.fn.drag=function(b,c,d){var e="string"==typeof b?b:"",f=a.isFunction(b)?b:a.isFunction(c)?c:null;return 0!==e.indexOf("drag")&&(e="drag"+e),d=(b==f?c:d)||{},f?this.bind(e,d,f):this.trigger(e)};var b=a.event,c=b.special,d=c.drag={defaults:{which:1,distance:0,not:":input",handle:null,relative:!1,drop:!0,click:!1},datakey:"dragdata",noBubble:!0,add:function(b){var c=a.data(this,d.datakey),e=b.data||{};c.related+=1,a.each(d.defaults,function(a){void 0!==e[a]&&(c[a]=e[a])})},remove:function(){a.data(this,d.datakey).related-=1},setup:function(){if(!a.data(this,d.datakey)){var c=a.extend({related:0},d.defaults);a.data(this,d.datakey,c),b.add(this,"touchstart mousedown",d.init,c),this.attachEvent&&this.attachEvent("ondragstart",d.dontstart)}},teardown:function(){var c=a.data(this,d.datakey)||{};c.related||(a.removeData(this,d.datakey),b.remove(this,"touchstart mousedown",d.init),d.textselect(!0),this.detachEvent&&this.detachEvent("ondragstart",d.dontstart))},init:function(e){if(!d.touched){var g,f=e.data;if(!(0!=e.which&&f.which>0&&e.which!=f.which||a(e.target).is(f.not)||f.handle&&!a(e.target).closest(f.handle,e.currentTarget).length||(d.touched="touchstart"==e.type?this:null,f.propagates=1,f.mousedown=this,f.interactions=[d.interaction(this,f)],f.target=e.target,f.pageX=e.pageX,f.pageY=e.pageY,f.dragging=null,g=d.hijack(e,"draginit",f),!f.propagates)))return g=d.flatten(g),g&&g.length&&(f.interactions=[],a.each(g,function(){f.interactions.push(d.interaction(this,f))})),f.propagates=f.interactions.length,f.drop!==!1&&c.drop&&c.drop.handler(e,f),d.textselect(!1),d.touched?b.add(d.touched,"touchmove touchend",d.handler,f):b.add(document,"mousemove mouseup",d.handler,f),!d.touched||f.live?!1:void 0}},interaction:function(b,c){var e=a(b)[c.relative?"position":"offset"]()||{top:0,left:0};return{drag:b,callback:new d.callback,droppable:[],offset:e}},handler:function(e){var f=e.data;switch(e.type){case!f.dragging&&"touchmove":e.preventDefault();case!f.dragging&&"mousemove":if(Math.pow(e.pageX-f.pageX,2)+Math.pow(e.pageY-f.pageY,2)++l);return c.type=i.type,c.originalEvent=i.event,d.flatten(f.results)}},properties:function(a,b,c){var e=c.callback;return e.drag=c.drag,e.proxy=c.proxy||c.drag,e.startX=b.pageX,e.startY=b.pageY,e.deltaX=a.pageX-b.pageX,e.deltaY=a.pageY-b.pageY,e.originalX=c.offset.left,e.originalY=c.offset.top,e.offsetX=e.originalX+e.deltaX,e.offsetY=e.originalY+e.deltaY,e.drop=d.flatten((c.drop||[]).slice()),e.available=d.flatten((c.droppable||[]).slice()),e},element:function(a){return a&&(a.jquery||1==a.nodeType)?a:void 0},flatten:function(b){return a.map(b,function(b){return b&&b.jquery?a.makeArray(b):b&&b.length?d.flatten(b):b})},textselect:function(b){a(document)[b?"unbind":"bind"]("selectstart",d.dontstart).css("MozUserSelect",b?"":"none"),document.unselectable=b?"off":"on"},dontstart:function(){return!1},callback:function(){}};d.callback.prototype={update:function(){c.drop&&this.available.length&&a.each(this.available,function(a){c.drop.locate(this,a)})}};var e=b.dispatch;b.dispatch=function(b){return a.data(this,"suppress."+b.type)-(new Date).getTime()>0?(a.removeData(this,"suppress."+b.type),void 0):e.apply(this,arguments)};var f=b.fixHooks.touchstart=b.fixHooks.touchmove=b.fixHooks.touchend=b.fixHooks.touchcancel={props:"clientX clientY pageX pageY screenX screenY".split(" "),filter:function(b,c){if(c){var d=c.touches&&c.touches[0]||c.changedTouches&&c.changedTouches[0]||null;d&&a.each(f.props,function(a,c){b[c]=d[c]})}return b}};c.draginit=c.dragstart=c.dragend=d})(jQuery);
diff --git a/public/js/external/slick.core.js b/public/js/external/slick.core.js
new file mode 100644
index 0000000..c517139
--- /dev/null
+++ b/public/js/external/slick.core.js
@@ -0,0 +1,7 @@
+/***
+ * Contains core SlickGrid classes.
+ * @module Core
+ * @namespace Slick
+ */
+
+(function(a){function b(){var a=!1,b=!1;this.stopPropagation=function(){a=!0},this.isPropagationStopped=function(){return a},this.stopImmediatePropagation=function(){b=!0},this.isImmediatePropagationStopped=function(){return b}}function c(){var a=[];this.subscribe=function(b){a.push(b)},this.unsubscribe=function(b){for(var c=a.length-1;c>=0;c--)a[c]===b&&a.splice(c,1)},this.notify=function(c,d,e){d=d||new b,e=e||this;for(var f,g=0;a.length>g&&!d.isPropagationStopped()&&!d.isImmediatePropagationStopped();g++)f=a[g].call(e,d,c);return f}}function d(){var a=[];this.subscribe=function(b,c){return a.push({event:b,handler:c}),b.subscribe(c),this},this.unsubscribe=function(b,c){for(var d=a.length;d--;)if(a[d].event===b&&a[d].handler===c)return a.splice(d,1),b.unsubscribe(c),void 0;return this},this.unsubscribeAll=function(){for(var b=a.length;b--;)a[b].event.unsubscribe(a[b].handler);return a=[],this}}function e(a,b,c,d){void 0===c&&void 0===d&&(c=a,d=b),this.fromRow=Math.min(a,c),this.fromCell=Math.min(b,d),this.toRow=Math.max(a,c),this.toCell=Math.max(b,d),this.isSingleRow=function(){return this.fromRow==this.toRow},this.isSingleCell=function(){return this.fromRow==this.toRow&&this.fromCell==this.toCell},this.contains=function(a,b){return a>=this.fromRow&&this.toRow>=a&&b>=this.fromCell&&this.toCell>=b},this.toString=function(){return this.isSingleCell()?"("+this.fromRow+":"+this.fromCell+")":"("+this.fromRow+":"+this.fromCell+" - "+this.toRow+":"+this.toCell+")"}}function f(){this.__nonDataRow=!0}function g(){this.__group=!0,this.level=0,this.count=0,this.value=null,this.title=null,this.collapsed=!1,this.totals=null,this.rows=[],this.groups=null,this.groupingKey=null}function h(){this.__groupTotals=!0,this.group=null}function i(){var a=null;this.isActive=function(b){return b?a===b:null!==a},this.activate=function(b){if(b!==a){if(null!==a)throw"SlickGrid.EditorLock.activate: an editController is still active, can't activate another editController";if(!b.commitCurrentEdit)throw"SlickGrid.EditorLock.activate: editController must implement .commitCurrentEdit()";if(!b.cancelCurrentEdit)throw"SlickGrid.EditorLock.activate: editController must implement .cancelCurrentEdit()";a=b}},this.deactivate=function(b){if(a!==b)throw"SlickGrid.EditorLock.deactivate: specified editController is not the currently active one";a=null},this.commitCurrentEdit=function(){return a?a.commitCurrentEdit():!0},this.cancelCurrentEdit=function(){return a?a.cancelCurrentEdit():!0}}a.extend(!0,window,{Slick:{Event:c,EventData:b,EventHandler:d,Range:e,NonDataRow:f,Group:g,GroupTotals:h,EditorLock:i,GlobalEditorLock:new i}}),g.prototype=new f,g.prototype.equals=function(a){return this.value===a.value&&this.count===a.count&&this.collapsed===a.collapsed},h.prototype=new f})(jQuery);
diff --git a/public/js/external/slick.editors.js b/public/js/external/slick.editors.js
new file mode 100644
index 0000000..7822366
--- /dev/null
+++ b/public/js/external/slick.editors.js
@@ -0,0 +1,7 @@
+/***
+ * Contains basic SlickGrid editors.
+ * @module Editors
+ * @namespace Slick
+ */
+
+(function($){$.extend(true,window,{"Slick":{"Editors":{"Text":TextEditor,"Integer":IntegerEditor,"Date":DateEditor,"YesNoSelect":YesNoSelectEditor,"Checkbox":CheckboxEditor,"PercentComplete":PercentCompleteEditor,"LongText":LongTextEditor}}});function TextEditor(args){var $input;var defaultValue;var scope=this;this.init=function(){$input=$("").appendTo(args.container).bind("keydown.nav",function(e){if(e.keyCode===$.ui.keyCode.LEFT||e.keyCode===$.ui.keyCode.RIGHT){e.stopImmediatePropagation()}}).focus().select()};this.destroy=function(){$input.remove()};this.focus=function(){$input.focus()};this.getValue=function(){return $input.val()};this.setValue=function(val){$input.val(val)};this.loadValue=function(item){defaultValue=item[args.column.field]||"";$input.val(defaultValue);$input[0].defaultValue=defaultValue;$input.select()};this.serializeValue=function(){return $input.val()};this.applyValue=function(item,state){item[args.column.field]=state};this.isValueChanged=function(){return(!($input.val()==""&&defaultValue==null))&&($input.val()!=defaultValue)};this.validate=function(){if(args.column.validator){var validationResults=args.column.validator($input.val());if(!validationResults.valid){return validationResults}}return{valid:true,msg:null}};this.init()}function IntegerEditor(args){var $input;var defaultValue;var scope=this;this.init=function(){$input=$("");$input.bind("keydown.nav",function(e){if(e.keyCode===$.ui.keyCode.LEFT||e.keyCode===$.ui.keyCode.RIGHT){e.stopImmediatePropagation()}});$input.appendTo(args.container);$input.focus().select()};this.destroy=function(){$input.remove()};this.focus=function(){$input.focus()};this.loadValue=function(item){defaultValue=item[args.column.field];$input.val(defaultValue);$input[0].defaultValue=defaultValue;$input.select()};this.serializeValue=function(){return parseInt($input.val(),10)||0};this.applyValue=function(item,state){item[args.column.field]=state};this.isValueChanged=function(){return(!($input.val()==""&&defaultValue==null))&&($input.val()!=defaultValue)};this.validate=function(){if(isNaN($input.val())){return{valid:false,msg:"Please enter a valid integer"}}return{valid:true,msg:null}};this.init()}function DateEditor(args){var $input;var defaultValue;var scope=this;var calendarOpen=false;this.init=function(){$input=$("");$input.appendTo(args.container);$input.focus().select();$input.datepicker({showOn:"button",buttonImageOnly:true,buttonImage:"../images/calendar.gif",beforeShow:function(){calendarOpen=true},onClose:function(){calendarOpen=false}});$input.width($input.width()-18)};this.destroy=function(){$.datepicker.dpDiv.stop(true,true);$input.datepicker("hide");$input.datepicker("destroy");$input.remove()};this.show=function(){if(calendarOpen){$.datepicker.dpDiv.stop(true,true).show()}};this.hide=function(){if(calendarOpen){$.datepicker.dpDiv.stop(true,true).hide()}};this.position=function(position){if(!calendarOpen){return}$.datepicker.dpDiv.css("top",position.top+30).css("left",position.left)};this.focus=function(){$input.focus()};this.loadValue=function(item){defaultValue=item[args.column.field];$input.val(defaultValue);$input[0].defaultValue=defaultValue;$input.select()};this.serializeValue=function(){return $input.val()};this.applyValue=function(item,state){item[args.column.field]=state};this.isValueChanged=function(){return(!($input.val()==""&&defaultValue==null))&&($input.val()!=defaultValue)};this.validate=function(){return{valid:true,msg:null}};this.init()}function YesNoSelectEditor(args){var $select;var defaultValue;var scope=this;this.init=function(){$select=$("");$select.appendTo(args.container);$select.focus()};this.destroy=function(){$select.remove()};this.focus=function(){$select.focus()};this.loadValue=function(item){$select.val((defaultValue=item[args.column.field])?"yes":"no");$select.select()};this.serializeValue=function(){return($select.val()=="yes")};this.applyValue=function(item,state){item[args.column.field]=state};this.isValueChanged=function(){return($select.val()!=defaultValue)};this.validate=function(){return{valid:true,msg:null}};this.init()}function CheckboxEditor(args){var $select;var defaultValue;var scope=this;this.init=function(){$select=$("");$select.appendTo(args.container);$select.focus()};this.destroy=function(){$select.remove()};this.focus=function(){$select.focus()};this.loadValue=function(item){defaultValue=!!item[args.column.field];if(defaultValue){$select.attr("checked","checked")}else{$select.removeAttr("checked")}};this.serializeValue=function(){return!!$select.attr("checked")};this.applyValue=function(item,state){item[args.column.field]=state};this.isValueChanged=function(){return(this.serializeValue()!==defaultValue)};this.validate=function(){return{valid:true,msg:null}};this.init()}function PercentCompleteEditor(args){var $input,$picker;var defaultValue;var scope=this;this.init=function(){$input=$("");$input.width($(args.container).innerWidth()-25);$input.appendTo(args.container);$picker=$("").appendTo(args.container);$picker.append("