From 6f518643bd9156db5550857c6ed256627df485a5 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Sun, 24 Jun 2012 20:20:36 +0100 Subject: [PATCH 01/14] [#168,docs/index][s]: ReclineJS architecture diagram. --- docs/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.html b/docs/index.html index eb5b787d..f5128c6c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -42,6 +42,8 @@ root: ../ + +

Tutorials

Note: A quick read through of the Concepts section will From 5216e23f4564d9234b0ebe6a6de83376624814e9 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 25 Jun 2012 09:30:37 +0100 Subject: [PATCH 02/14] [model/query][s]: tidying up last part of recent filter refactor by removing addTermFilter + addGeoFilter - closes #154. --- src/model.js | 35 ----------------------------------- test/model.test.js | 6 ------ 2 files changed, 41 deletions(-) diff --git a/src/model.js b/src/model.js index cd14613e..3ac84c46 100644 --- a/src/model.js +++ b/src/model.js @@ -480,41 +480,6 @@ my.Query = Backbone.Model.extend({ }, updateFilter: function(index, value) { }, - // #### addTermFilter - // - // Set (update or add) a terms filter to filters - // - // See - addTermFilter: function(fieldId, value) { - var filters = this.get('filters'); - var filter = { term: {} }; - filter.term[fieldId] = value || ''; - filters.push(filter); - this.set({filters: filters}); - // change does not seem to be triggered automatically - if (value) { - this.trigger('change'); - } else { - // adding a new blank filter and do not want to trigger a new query - this.trigger('change:filters:new-blank'); - } - }, - addGeoDistanceFilter: function(field) { - var filters = this.get('filters'); - var filter = { - geo_distance: { - distance: '10km', - } - }; - filter.geo_distance[field] = { - 'lon': 0, - 'lat': 0 - }; - filters.push(filter); - this.set({filters: filters}); - // adding a new blank filter and do not want to trigger a new query - this.trigger('change:filters:new-blank'); - }, // ### removeFilter // // Remove a filter from filters at index filterIndex diff --git a/test/model.test.js b/test/model.test.js index 0dc01440..9300c274 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -274,10 +274,4 @@ test('Query.addFilter', function () { deepEqual(exp, query.get('filters')[1]); }); -test('Query.addTermFilter', function () { - var query = new recline.Model.Query(); - query.addTermFilter('xyz', 'this-value'); - deepEqual({term: {xyz: 'this-value'}}, query.get('filters')[0]); -}); - })(this.jQuery); From 3d6ad46cc522df8e90a2e7b107cb2d7a0235146a Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Mon, 25 Jun 2012 10:10:12 +0100 Subject: [PATCH 03/14] [#162,refactor][s]: backend is now string in 'normal' set of Dataset arguments. --- app/js/app.js | 16 +++++++-------- src/model.js | 33 ++++++++++-------------------- test/backend/csv.test.js | 7 +++---- test/backend/dataproxy.test.js | 6 +++--- test/backend/elasticsearch.test.js | 14 ++++++------- test/backend/gdocs.test.js | 7 +++---- 6 files changed, 34 insertions(+), 49 deletions(-) diff --git a/app/js/app.js b/app/js/app.js index 082a7cf7..b849f5cc 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -192,7 +192,8 @@ var ExplorerApp = Backbone.View.extend({ } type = 'elasticsearch'; } - var dataset = new recline.Model.Dataset(datasetInfo, type); + datasetInfo.backend = type; + var dataset = new recline.Model.Dataset(datasetInfo); this.createExplorer(dataset); }, @@ -203,13 +204,12 @@ var ExplorerApp = Backbone.View.extend({ $('.modal.js-load-dialog-file').modal('hide'); var $file = $form.find('input[type="file"]')[0]; var dataset = new recline.Model.Dataset({ - file: $file.files[0], - separator : $form.find('input[name="separator"]').val(), - delimiter : $form.find('input[name="delimiter"]').val(), - encoding : $form.find('input[name="encoding"]').val() - }, - 'csv' - ); + file: $file.files[0], + separator : $form.find('input[name="separator"]').val(), + delimiter : $form.find('input[name="delimiter"]').val(), + encoding : $form.find('input[name="encoding"]').val(), + backend: 'csv' + }); dataset.fetch().done(function() { self.createExplorer(dataset) }); diff --git a/src/model.js b/src/model.js index 3ac84c46..2266c290 100644 --- a/src/model.js +++ b/src/model.js @@ -9,28 +9,16 @@ my.Dataset = Backbone.Model.extend({ __type__: 'Dataset', // ### initialize - // - // Sets up instance properties (see above) - // - // @param {Object} model: standard set of model attributes passed to Backbone models - // - // @param {Object or String} backend: Backend instance (see - // `recline.Backend.Base`) or a string specifying that instance. The - // string specifying may be a full class path e.g. - // 'recline.Backend.ElasticSearch' or a simple name e.g. - // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in - // recline.Backend module) - initialize: function(model, backend) { + initialize: function() { _.bindAll(this, 'query'); - this.backend = backend; - if (typeof backend === 'undefined') { + this.backend = null; + if (this.get('backend')) { + this.backend = this._backendFromString(this.get('backend')); + } else { // try to guess backend ... if (this.get('records')) { this.backend = recline.Backend.Memory; } } - if (typeof(backend) === 'string') { - this.backend = this._backendFromString(backend); - } this.fields = new my.FieldList(); this.currentRecords = new my.RecordList(); this._changes = { @@ -43,6 +31,9 @@ my.Dataset = Backbone.Model.extend({ this.queryState = new my.Query(); this.queryState.bind('change', this.query); this.queryState.bind('facet:add', this.query); + // store is what we query and save against + // store will either be the backend or be a memory store if Backend fetch + // tells us to use memory store this._store = this.backend; if (this.backend == recline.Backend.Memory) { this.fetch(); @@ -298,13 +289,11 @@ my.Dataset.restore = function(state) { }; } else { var datasetInfo = { - url: state.url + url: state.url, + backend: state.backend }; } - dataset = new recline.Model.Dataset( - datasetInfo, - state.backend - ); + dataset = new recline.Model.Dataset(datasetInfo); return dataset; }; diff --git a/test/backend/csv.test.js b/test/backend/csv.test.js index be4c79e6..4c684227 100644 --- a/test/backend/csv.test.js +++ b/test/backend/csv.test.js @@ -25,10 +25,9 @@ test("parseCSV", function() { '"Xyz ""ABC"" O\'Brien", 11:35\n' + '"Other, AN", 12:35\n'; var dataset = new recline.Model.Dataset({ - data: csv - }, - 'csv' - ); + data: csv, + backend: 'csv' + }); dataset.fetch(); equal(dataset.currentRecords.length, 3); var row = dataset.currentRecords.models[0].toJSON(); diff --git a/test/backend/dataproxy.test.js b/test/backend/dataproxy.test.js index 660b867b..2c873ab1 100644 --- a/test/backend/dataproxy.test.js +++ b/test/backend/dataproxy.test.js @@ -71,9 +71,9 @@ test('DataProxy Backend', function() { equal(backend.__type__, 'dataproxy'); var dataset = new recline.Model.Dataset({ - url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv' - }, - 'dataproxy' + url: 'http://webstore.thedatahub.org/rufuspollock/gold_prices/data.csv', + backend: 'dataproxy' + } ); var stub = sinon.stub($, 'ajax', function(options) { diff --git a/test/backend/elasticsearch.test.js b/test/backend/elasticsearch.test.js index 4ab6aa07..a4434961 100644 --- a/test/backend/elasticsearch.test.js +++ b/test/backend/elasticsearch.test.js @@ -250,10 +250,9 @@ module("Backend ElasticSearch - Recline"); test("query", function() { var dataset = new recline.Model.Dataset({ - url: 'https://localhost:9200/my-es-db/my-es-type' - }, - 'elasticsearch' - ); + url: 'https://localhost:9200/my-es-db/my-es-type', + backend: 'elasticsearch' + }); var stub = sinon.stub($, 'ajax', function(options) { if (options.url.indexOf('_mapping') != -1) { @@ -292,10 +291,9 @@ test("query", function() { test("write", function() { var dataset = new recline.Model.Dataset({ - url: 'http://localhost:9200/recline-test/es-write' - }, - 'elasticsearch' - ); + url: 'http://localhost:9200/recline-test/es-write', + backend: 'elasticsearch' + }); stop(); diff --git a/test/backend/gdocs.test.js b/test/backend/gdocs.test.js index 4846621d..c5ab0d52 100644 --- a/test/backend/gdocs.test.js +++ b/test/backend/gdocs.test.js @@ -169,10 +169,9 @@ var sample_gdocs_spreadsheet_data = { test("GDocs Backend", function() { var dataset = new recline.Model.Dataset({ - url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json' - }, - 'gdocs' - ); + url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json', + backend: 'gdocs' + }); var stub = sinon.stub($, 'getJSON', function(options, cb) { var partialUrl = 'spreadsheets.google.com'; From 39516876fab278b84ebf133012c2c42d91d80033 Mon Sep 17 00:00:00 2001 From: Salman Haq Date: Mon, 25 Jun 2012 15:08:39 -0400 Subject: [PATCH 04/14] [#162] refactor couchdb backend. --- src/backend/couchdb.js | 590 ++++++++++++++++++++++------------------- 1 file changed, 311 insertions(+), 279 deletions(-) diff --git a/src/backend/couchdb.js b/src/backend/couchdb.js index f25e3acc..46540d8e 100644 --- a/src/backend/couchdb.js +++ b/src/backend/couchdb.js @@ -3,6 +3,8 @@ this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {}; (function($, my) { + my.__type__ = 'couchdb'; + // ## CouchDB Wrapper // // Connecting to [CouchDB] (http://www.couchdb.apache.org/) endpoints. @@ -32,7 +34,7 @@ this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {}; } }; } - var data = _.extend(extras, data); + data = _.extend(extras, data); return $.ajax(data); }; @@ -150,314 +152,344 @@ this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {}; // // Usage: // - // var backend = new recline.Backend.CouchDB({ + // var backend = new recline.Backend.CouchDB(); + // var dataset = new recline.Model.Dataset({ // db_url: '/couchdb/mydb', // view_url: '/couchdb/mydb/_design/design1/_views/view1', // query_options: { // 'key': 'some_document_key' // } // }); + // backend.fetch(dataset.toJSON()); + // backend.query(query, dataset.toJSON()).done(function () { ... }); // - // If these options are not passed to the constructor, the model - // object is checked for the presence of these arguments. - // - // Additionally, the Dataset instance may define two methods: + // Additionally, the Dataset instance may define three methods: // function record_update (record, document) { ... } // function record_delete (record, document) { ... } + // function record_create (record, document) { ... } // Where `record` is the JSON representation of the Record/Document instance // and `document` is the JSON document stored in couchdb. // When _all_docs view is used (default), a record is the same as a document // so these methods need not be defined. // They are most useful when using a custom view that performs a map-reduce // operation on each document to yield a record. Hence, when the record is - // updated or deleted, an inverse operation must be performed on the original + // created, updated or deleted, an inverse operation must be performed on the original // document. // // @param {string} url of couchdb database. // @param {string} (optional) url of couchdb view. default:`db_url`/_all_docs // @param {Object} (optional) query options accepted by couchdb views. - // @param {string} (optional) url of Elastic Search engine. // - my.Backbone = function(options) { - var self = this; - this.__type__ ='couchdb'; - this.db_url = options['db_url'] || ''; - this.view_url = options['view_url'] || this.db_url + '/_all_docs'; - this.query_options = options['query_options'] || {}; - // ### sync - // - // Backbone sync implementation for this backend. - // - this.sync = function (method, model, options) { - var dataset = null - if (model.__type__ == 'Dataset') - dataset = model; - else - dataset = model.dataset; + my.couchOptions = {}; - var db_url = dataset.get('db_url') || self.db_url; - var view_url = dataset.get('view_url') || self.view_url; - var query_options = dataset.get('query_options') || self.query_options; + // ### fetch + // @param {object} dataset json object with the db_url, view_url, and query_options args. + // @return promise object that resolves to the document mapping. + my.fetch = function (dataset) { + var db_url = dataset.db_url; + var view_url = dataset.view_url; + var cdb = new my.CouchDBWrapper(db_url, view_url); + var dfd = $.Deferred(); - var cdb = new my.CouchDBWrapper(db_url, view_url); - - if (method === "read") { - if (model.__type__ == 'Dataset') { - var dfd = $.Deferred(); - - // if 'doc' attribute is present, return schema of that - // else return schema of 'value' attribute which contains - // the map-reduce document. - cdb.mapping().done(function(result) { - var row = result.rows[0]; - var keys = []; - if (view_url.search("_all_docs") !== -1) { - keys = _.keys(row['doc']); - keys = _.filter(keys, function (k) { return k.charAt(0) !== '_' }); - } - else { - keys = _.keys(row['value']); - } - - var fieldData = _.map(keys, function(k) { - return { 'id' : k }; - }); - model.fields.reset(fieldData); - - dfd.resolve(model); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } else if (model.__type__ == 'Record' || model.__type__ == 'Document') { - if (view_url.search("_all_docs") !== -1) { - return cdb.get(model.get('_id')); - } - else { - var dfd = $.Deferred(); - // decompose _id - var id = model.get('_id').split('__')[0]; - var key = model.get('_id').split('__')[1]; - var jqxhr = cdb.query(model.dataset.get('query_options')); - - jqxhr.done(function(records) { - var doc = {}; - var rec = _.filter(records, function (record) { - record['id'] == id && record['key'] == key; - }); - doc = rec[0]; // XXX check len first - doc['id'] = doc['_id'] = model.get('_id'); - dfd.resolve(doc); - }).fail(function(args) { - dfd.reject(args); - }); - return dfd.promise(); - } - } - } else if (method === 'update') { - if (model.__type__ == 'Record' || model.__type__ == 'Document') { - var dfd = $.Deferred(); - var _id = null; - var jqxhr; - var new_doc = model.toJSON(); - // couchdb uses _id to identify documents, Backbone models use id. - // we should remove it before sending it to the server. - delete new_doc['id']; - if (view_url.search('_all_docs') !== -1) { - _id = model.get('_id'); - jqxhr = cdb.get(_id); - } - else { - _id = model.get('_id').split('__')[0]; - jqxhr = cdb.get(_id); - } - - jqxhr.done(function(old_doc){ - if (model.dataset.record_update) - new_doc = model.dataset.record_update(new_doc, old_doc); - new_doc = _.extend(old_doc, new_doc); - new_doc['_id'] = _id; - // XXX upsert can fail during a bulk column transform due to revision conflict. - // the correct way to handle bulk column transforms is to use - // a queue which is processed by a web worker. - dfd.resolve(cdb.upsert(new_doc)); - }).fail(function(args){ - dfd.reject(args); - }); - - return dfd.promise(); - } - } else if (method === 'delete') { - if (model.__type__ == 'Record' || model.__type__ == 'Document') { - if (view_url.search('_all_docs') !== -1) - return cdb.delete(model.get('_id')); - else { - var dfd = $.Deferred(); - var _id = model.get('_id').split('__')[0]; - var new_doc = null; - var jqxhr = cdb.get(_id); - - jqxhr.done(function(old_doc){ - if (model.dataset.record_delete) - new_doc = model.dataset.record_delete(model.toJSON(), old_doc); - if (_.isNull(new_doc)) - dfd.resolve(cdb.delete(_id)); // XXX is this the right thing to do? - else { - // couchdb uses _id to identify documents, Backbone models use id. - // we should remove it before sending it to the server. - new_doc['_id'] = _id; - delete new_doc['id']; - dfd.resolve(cdb.upsert(new_doc)); - } - }).fail(function(args){ - dfd.reject(args); - }); - return dfd.promise(); - } - } + // if 'doc' attribute is present, return schema of that + // else return schema of 'value' attribute which contains + // the map-reduced document. + cdb.mapping().done(function(result) { + var row = result.rows[0]; + var keys = []; + if (view_url.search("_all_docs") !== -1) { + keys = _.keys(row['doc']); + keys = _.filter(keys, function (k) { return k.charAt(0) !== '_' }); } - - }, - - // ### query - // - // fetch the data from the couchdb view and filter it. - // @param {Object} recline.Dataset instance - // @param {Object} recline.Query instance. - this.query = function(model, queryObj) { - var dfd = $.Deferred(); - var db_url = model.get('db_url') || self.db_url; - var view_url = model.get('view_url') || self.view_url; - var query_options = model.get('query_options') || self.query_options; - - var cdb = new my.CouchDBWrapper(db_url, view_url); - var cdb_q = cdb._normalizeQuery(queryObj, query_options); - - cdb.query(queryObj, query_options).done(function(records){ - - var query_result = { hits: [], total: 0 }; - _.each(records.rows, function(record) { - var doc = {}; - if (record.hasOwnProperty('doc')) { - doc['_source'] = record['doc']; - // couchdb uses _id to identify documents, Backbone models use id. - // we add this fix so backbone.Model works correctly. - doc['_source']['id'] = doc['_source']['_id']; - } - else { - doc['_source'] = record['value']; - // using dunder to create compound id. need something more robust. - doc['_source']['_id'] = record['id'] + '__' + record['key']; - // couchdb uses _id to identify documents, Backbone models use id. - // we add this fix so backbone.Model works correctly. - doc['_source']['id'] = doc['_source']['_id']; - } - query_result.total += 1; - query_result.hits.push(doc); - }); - - // the following block is borrowed verbatim from recline.backend.Memory - // search (with filtering, faceting, and sorting) should be factored - // out into a separate library. - query_result.hits = self._applyFilters(query_result.hits, queryObj); - query_result.hits = self._applyFreeTextQuery(query_result.hits, queryObj); - // not complete sorting! - _.each(queryObj.sort, function(sortObj) { - var fieldName = _.keys(sortObj)[0]; - query_result.hits = _.sortBy(query_result.hits, function(doc) { - var _out = doc[fieldName]; - return (sortObj[fieldName].order == 'asc') ? _out : -1*_out; - }); - }); - query_result.total = query_result.hits.length; - query_result.facets = self.computeFacets(query_result.hits, queryObj); - query_result.hits = query_result.hits.slice(cdb_q.skip, cdb_q.skip + cdb_q.limit+1); - dfd.resolve(query_result); - - }); - - return dfd.promise(); - }, - - // in place filtering - _applyFilters: function(results, queryObj) { - _.each(queryObj.filters, function(filter) { - results = _.filter(results, function(doc) { - var fieldId = _.keys(filter.term)[0]; - return (doc['_source'][fieldId] == filter.term[fieldId]); - }); - }); - return results; - }, - - // we OR across fields but AND across terms in query string - _applyFreeTextQuery: function(results, queryObj) { - if (queryObj.q) { - var terms = queryObj.q.split(' '); - results = _.filter(results, function(rawdoc) { - rawdoc = rawdoc['_source']; - var matches = true; - _.each(terms, function(term) { - var foundmatch = false; - //_.each(self.fields, function(field) { - _.each(_.keys(rawdoc), function(field) { - var value = rawdoc[field]; - if (value !== null) { value = value.toString(); } - // TODO regexes? - foundmatch = foundmatch || (value === term); - // TODO: early out (once we are true should break to spare unnecessary testing) - // if (foundmatch) return true; - }); - matches = matches && foundmatch; - // TODO: early out (once false should break to spare unnecessary testing) - // if (!matches) return false; - }); - return matches; - }); + else { + keys = _.keys(row['value']); } - return results; - }, - computeFacets: function(records, queryObj) { - var facetResults = {}; - if (!queryObj.facets) { - return facetResults; + var fieldData = _.map(keys, function(k) { + return { 'id' : k }; + }); + dfd.resolve({ + fields: fieldData + }); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }; + +// ### save +// +// Iterate through all the changes and save them to the server. +// N.B. This method is asynchronous and attempts to do multiple +// operation concurrently. This can be problematic when more than +// one operation is requested on the same document (as in the case +// of bulk column transforms). +// +// @param {object} lists of create, update, delete. +// @param {object} dataset json object. +// +// +my.save = function (changes, dataset) { + var dfd = $.Deferred(); + var total = changes.creates.length + changes.updates.length + changes.deletes.length; + var results = {'done': [], 'fail': [] }; + + var decr_cb = function () { total -= 1; } + var resolve_cb = function () { if (total == 0) dfd.resolve(results); } + + for (var i in changes.creates) { + var new_doc = changes.creates[i]; + var succ_cb = function (msg) {results.done.push({'op': 'create', 'record': new_doc, 'reason': ''}); } + var fail_cb = function (msg) {results.fail.push({'op': 'create', 'record': new_doc, 'reason': msg}); } + + my._create_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + } + + for (var i in changes.updates) { + var new_doc = changes.updates[i]; + var succ_cb = function (msg) {results.done.push({'op': 'update', 'record': new_doc, 'reason': ''}); } + var fail_cb = function (msg) {results.fail.push({'op': 'update', 'record': new_doc, 'reason': msg}); } + + my._update_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + } + + for (var i in changes.deletes) { + var old_doc = changes.deletes[i]; + var succ_cb = function (msg) {results.done.push({'op': 'delete', 'record': old_doc, 'reason': ''}); } + var fail_cb = function (msg) {results.fail.push({'op': 'delete', 'record': old_doc, 'reason': msg}); } + + my._delete_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + } + + return dfd.promise(); +}; + + +// ### query +// +// fetch the data from the couchdb view and filter it. +// @param {Object} recline.Dataset instance +// @param {Object} recline.Query instance. +my.query = function(queryObj, dataset) { + var dfd = $.Deferred(); + var db_url = dataset.db_url; + var view_url = dataset.view_url; + var query_options = dataset.query_options; + + var cdb = new my.CouchDBWrapper(db_url, view_url); + var cdb_q = cdb._normalizeQuery(queryObj, query_options); + + cdb.query(queryObj, query_options).done(function(records){ + + var query_result = { hits: [], total: 0 }; + _.each(records.rows, function(record) { + var doc = {}; + if (record.hasOwnProperty('doc')) { + doc = record['doc']; + // couchdb uses _id to identify documents, Backbone models use id. + // we add this fix so backbone.Model works correctly. + doc['id'] = doc['_id']; } - _.each(queryObj.facets, function(query, facetId) { - // TODO: remove dependency on recline.Model - facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); - facetResults[facetId].termsall = {}; + else { + doc = record['value']; + // using dunder to create compound id. need something more robust. + // couchdb uses _id to identify documents, Backbone models use id. + // we add this fix so backbone.Model works correctly. + doc['_id'] = doc['id'] = record['id'] + '__' + record['key']; + } + query_result.total += 1; + query_result.hits.push(doc); + }); + + // the following block is borrowed verbatim from recline.backend.Memory + // search (with filtering, faceting, and sorting) should be factored + // out into a separate library. + query_result.hits = self._applyFilters(query_result.hits, queryObj); + query_result.hits = self._applyFreeTextQuery(query_result.hits, queryObj); + // not complete sorting! + _.each(queryObj.sort, function(sortObj) { + var fieldName = _.keys(sortObj)[0]; + query_result.hits = _.sortBy(query_result.hits, function(doc) { + var _out = doc[fieldName]; + return (sortObj[fieldName].order == 'asc') ? _out : -1*_out; }); - // faceting - _.each(records, function(doc) { - _.each(queryObj.facets, function(query, facetId) { - var fieldId = query.terms.field; - var val = doc['_source'][fieldId]; - var tmp = facetResults[facetId]; - if (val) { - tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1; - } else { - tmp.missing = tmp.missing + 1; - } - }); - }); - _.each(queryObj.facets, function(query, facetId) { - var tmp = facetResults[facetId]; - var terms = _.map(tmp.termsall, function(count, term) { - return { term: term, count: count }; - }); - tmp.terms = _.sortBy(terms, function(item) { - // want descending order - return -item.count; - }); - tmp.terms = tmp.terms.slice(0, 10); - }); - return facetResults; - }, - + }); + query_result.total = query_result.hits.length; + query_result.facets = self.computeFacets(query_result.hits, queryObj); + query_result.hits = query_result.hits.slice(cdb_q.skip, cdb_q.skip + cdb_q.limit+1); + dfd.resolve(query_result); }); + + return dfd.promise(); +}; + +// in place filtering +my._applyFilters = function(results, queryObj) { + _.each(queryObj.filters, function(filter) { + results = _.filter(results, function(doc) { + var fieldId = _.keys(filter.term)[0]; + return (doc[fieldId] == filter.term[fieldId]); + }); + }); + return results; +}; + +// we OR across fields but AND across terms in query string +my._applyFreeTextQuery = function(results, queryObj) { + if (queryObj.q) { + var terms = queryObj.q.split(' '); + results = _.filter(results, function(rawdoc) { + var matches = true; + _.each(terms, function(term) { + var foundmatch = false; + //_.each(self.fields, function(field) { + _.each(_.keys(rawdoc), function(field) { + var value = rawdoc[field]; + if (value !== null) { value = value.toString(); } + // TODO regexes? + foundmatch = foundmatch || (value === term); + // TODO: early out (once we are true should break to spare unnecessary testing) + // if (foundmatch) return true; + }); + matches = matches && foundmatch; + // TODO: early out (once false should break to spare unnecessary testing) + // if (!matches) return false; + }); + return matches; + }); + } + return results; +}; + +my.computeFacets = function(records, queryObj) { + var facetResults = {}; + if (!queryObj.facets) { + return facetResults; + } + _.each(queryObj.facets, function(query, facetId) { + // TODO: remove dependency on recline.Model + facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); + facetResults[facetId].termsall = {}; + }); + // faceting + _.each(records, function(doc) { + _.each(queryObj.facets, function(query, facetId) { + var fieldId = query.terms.field; + var val = doc[fieldId]; + var tmp = facetResults[facetId]; + if (val) { + tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1; + } else { + tmp.missing = tmp.missing + 1; + } + }); + }); + _.each(queryObj.facets, function(query, facetId) { + var tmp = facetResults[facetId]; + var terms = _.map(tmp.termsall, function(count, term) { + return { term: term, count: count }; + }); + tmp.terms = _.sortBy(terms, function(item) { + // want descending order + return -item.count; + }); + tmp.terms = tmp.terms.slice(0, 10); + }); + return facetResults; +}; + +my._create_document = function (new_doc, dataset) { + var dfd = $.Deferred(); + var db_url = dataset.db_url; + var view_url = dataset.view_url; + var _id = new_doc['id']; + var cdb = new my.CouchDBWrapper(db_url, view_url); + + delete new_doc['id']; + + if (view_url.search('_all_docs') !== -1) { + jqxhr = cdb.get(_id); + } + else { + _id = new_doc['_id'].split('__')[0]; + jqxhr = cdb.get(_id); + } + + jqxhr.done(function(old_doc){ + if (dataset.record_create) + new_doc = dataset.record_create(new_doc, old_doc); + new_doc = _.extend(old_doc, new_doc); + new_doc['_id'] = _id; + dfd.resolve(cdb.upsert(new_doc)); + }).fail(function(args){ + dfd.reject(args); + }); + + return dfd.promise(); +}; + +my._update_document = function (new_doc, dataset) { + var dfd = $.Deferred(); + var db_url = dataset.db_url; + var view_url = dataset.view_url; + var _id = new_doc['id']; + var cdb = new my.CouchDBWrapper(db_url, view_url); + + delete new_doc['id']; + + if (view_url.search('_all_docs') !== -1) { + jqxhr = cdb.get(_id); + } + else { + _id = new_doc['_id'].split('__')[0]; + jqxhr = cdb.get(_id); + } + + jqxhr.done(function(old_doc){ + if (dataset.record_update) + new_doc = dataset.record_update(new_doc, old_doc); + new_doc = _.extend(old_doc, new_doc); + new_doc['_id'] = _id; + dfd.resolve(cdb.upsert(new_doc)); + }).fail(function(args){ + dfd.reject(args); + }); + + return dfd.promise(); +}; + +my._delete_document = function (del_doc, dataset) { + var dfd = $.Deferred(); + var db_url = dataset.db_url; + var view_url = dataset.view_url; + var _id = del_doc['id']; + var cdb = new my.CouchDBWrapper(db_url, view_url); + + if (view_url.search('_all_docs') !== -1) + return cdb.delete(_id); + else { + _id = model.get('_id').split('__')[0]; + var jqxhr = cdb.get(_id); + + jqxhr.done(function(old_doc){ + if (dataset.record_delete) + old_doc = dataset.record_delete(del_doc, old_doc); + if (_.isNull(del_doc)) + dfd.resolve(cdb.delete(_id)); // XXX is this the right thing to do? + else { + // couchdb uses _id to identify documents, Backbone models use id. + // we should remove it before sending it to the server. + old_doc['_id'] = _id; + delete old_doc['id']; + dfd.resolve(cdb.upsert(old_doc)); + } + }).fail(function(args){ + dfd.reject(args); + }); + return dfd.promise(); +}; }(jQuery, this.recline.Backend.CouchDB)); From fb9d154834775f468d33c6f9ada6e49364c43796 Mon Sep 17 00:00:00 2001 From: Salman Haq Date: Tue, 26 Jun 2012 09:55:46 -0400 Subject: [PATCH 05/14] #162 minor stylistic changes based on feedback. --- src/backend/couchdb.js | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/backend/couchdb.js b/src/backend/couchdb.js index 46540d8e..3fa930f2 100644 --- a/src/backend/couchdb.js +++ b/src/backend/couchdb.js @@ -163,6 +163,11 @@ this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {}; // backend.fetch(dataset.toJSON()); // backend.query(query, dataset.toJSON()).done(function () { ... }); // + // Alternatively: + // var dataset = new recline.Model.Dataset({ ... }, 'couchdb'); + // dataset.fetch(); + // var results = dataset.query(query_obj); + // // Additionally, the Dataset instance may define three methods: // function record_update (record, document) { ... } // function record_delete (record, document) { ... } @@ -244,7 +249,7 @@ my.save = function (changes, dataset) { var succ_cb = function (msg) {results.done.push({'op': 'create', 'record': new_doc, 'reason': ''}); } var fail_cb = function (msg) {results.fail.push({'op': 'create', 'record': new_doc, 'reason': msg}); } - my._create_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + _createDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); } for (var i in changes.updates) { @@ -252,7 +257,7 @@ my.save = function (changes, dataset) { var succ_cb = function (msg) {results.done.push({'op': 'update', 'record': new_doc, 'reason': ''}); } var fail_cb = function (msg) {results.fail.push({'op': 'update', 'record': new_doc, 'reason': msg}); } - my._update_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + _updateDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); } for (var i in changes.deletes) { @@ -260,7 +265,7 @@ my.save = function (changes, dataset) { var succ_cb = function (msg) {results.done.push({'op': 'delete', 'record': old_doc, 'reason': ''}); } var fail_cb = function (msg) {results.fail.push({'op': 'delete', 'record': old_doc, 'reason': msg}); } - my._delete_document(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); + _deleteDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]); } return dfd.promise(); @@ -306,8 +311,8 @@ my.query = function(queryObj, dataset) { // the following block is borrowed verbatim from recline.backend.Memory // search (with filtering, faceting, and sorting) should be factored // out into a separate library. - query_result.hits = self._applyFilters(query_result.hits, queryObj); - query_result.hits = self._applyFreeTextQuery(query_result.hits, queryObj); + query_result.hits = _applyFilters(query_result.hits, queryObj); + query_result.hits = _applyFreeTextQuery(query_result.hits, queryObj); // not complete sorting! _.each(queryObj.sort, function(sortObj) { var fieldName = _.keys(sortObj)[0]; @@ -317,7 +322,7 @@ my.query = function(queryObj, dataset) { }); }); query_result.total = query_result.hits.length; - query_result.facets = self.computeFacets(query_result.hits, queryObj); + query_result.facets = _computeFacets(query_result.hits, queryObj); query_result.hits = query_result.hits.slice(cdb_q.skip, cdb_q.skip + cdb_q.limit+1); dfd.resolve(query_result); @@ -327,7 +332,7 @@ my.query = function(queryObj, dataset) { }; // in place filtering -my._applyFilters = function(results, queryObj) { +_applyFilters = function(results, queryObj) { _.each(queryObj.filters, function(filter) { results = _.filter(results, function(doc) { var fieldId = _.keys(filter.term)[0]; @@ -338,15 +343,14 @@ my._applyFilters = function(results, queryObj) { }; // we OR across fields but AND across terms in query string -my._applyFreeTextQuery = function(results, queryObj) { +_applyFreeTextQuery = function(results, queryObj) { if (queryObj.q) { var terms = queryObj.q.split(' '); results = _.filter(results, function(rawdoc) { var matches = true; _.each(terms, function(term) { - var foundmatch = false; - //_.each(self.fields, function(field) { - _.each(_.keys(rawdoc), function(field) { + var foundmatch = false; + _.each(_.keys(rawdoc), function(field) { var value = rawdoc[field]; if (value !== null) { value = value.toString(); } // TODO regexes? @@ -364,7 +368,7 @@ my._applyFreeTextQuery = function(results, queryObj) { return results; }; -my.computeFacets = function(records, queryObj) { +_computeFacets = function(records, queryObj) { var facetResults = {}; if (!queryObj.facets) { return facetResults; @@ -401,7 +405,7 @@ my.computeFacets = function(records, queryObj) { return facetResults; }; -my._create_document = function (new_doc, dataset) { +_createDocument = function (new_doc, dataset) { var dfd = $.Deferred(); var db_url = dataset.db_url; var view_url = dataset.view_url; @@ -431,7 +435,7 @@ my._create_document = function (new_doc, dataset) { return dfd.promise(); }; -my._update_document = function (new_doc, dataset) { +_updateDocument = function (new_doc, dataset) { var dfd = $.Deferred(); var db_url = dataset.db_url; var view_url = dataset.view_url; @@ -461,7 +465,7 @@ my._update_document = function (new_doc, dataset) { return dfd.promise(); }; -my._delete_document = function (del_doc, dataset) { +_deleteDocument = function (del_doc, dataset) { var dfd = $.Deferred(); var db_url = dataset.db_url; var view_url = dataset.view_url; From 340fedde0da521474f3a136e6c98edfdf38f84b1 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 26 Jun 2012 20:38:28 +0100 Subject: [PATCH 06/14] [refactor][s]: get rid of backend subdirectory and instead have prefix backend when naming files. --- make | 9 +++----- .../couchdb.js => backend.couchdb.js} | 0 src/{backend/csv.js => backend.csv.js} | 0 .../dataproxy.js => backend.dataproxy.js} | 0 ...sticsearch.js => backend.elasticsearch.js} | 0 src/{backend/gdocs.js => backend.gdocs.js} | 0 src/{backend/memory.js => backend.memory.js} | 0 .../csv.test.js => backend.csv.test.js} | 0 ...roxy.test.js => backend.dataproxy.test.js} | 0 ....test.js => backend.elasticsearch.test.js} | 0 .../gdocs.test.js => backend.gdocs.test.js} | 0 .../memory.test.js => backend.memory.test.js} | 0 test/index.html | 22 +++++++++---------- 13 files changed, 14 insertions(+), 17 deletions(-) rename src/{backend/couchdb.js => backend.couchdb.js} (100%) rename src/{backend/csv.js => backend.csv.js} (100%) rename src/{backend/dataproxy.js => backend.dataproxy.js} (100%) rename src/{backend/elasticsearch.js => backend.elasticsearch.js} (100%) rename src/{backend/gdocs.js => backend.gdocs.js} (100%) rename src/{backend/memory.js => backend.memory.js} (100%) rename test/{backend/csv.test.js => backend.csv.test.js} (100%) rename test/{backend/dataproxy.test.js => backend.dataproxy.test.js} (100%) rename test/{backend/elasticsearch.test.js => backend.elasticsearch.test.js} (100%) rename test/{backend/gdocs.test.js => backend.gdocs.test.js} (100%) rename test/{backend/memory.test.js => backend.memory.test.js} (100%) diff --git a/make b/make index 7b02912b..bbd3430a 100755 --- a/make +++ b/make @@ -5,7 +5,7 @@ import os def cat(): print("** Combining js files") - cmd = 'cat src/*.js src/backend/*.js > dist/recline.js' + cmd = 'cat src/*.js > dist/recline.js' os.system(cmd) print("** Combining css files") cmd = 'cat css/*.css > dist/recline.css' @@ -16,14 +16,11 @@ def docs(): print("** Building docs") docco_executable = os.environ.get('DOCCO_EXECUTABLE','docco') - cmd = '%s src/*.js' % (docco_executable) - os.system(cmd) if os.path.exists('/tmp/recline-docs'): shutil.rmtree('/tmp/recline-docs') os.makedirs('/tmp/recline-docs') - os.system('mkdir -p docs/backend') - files = '%s/src/backend/*.js' % os.getcwd() - dest = '%s/docs/backend' % os.getcwd() + files = '%s/src/*.js' % os.getcwd() + dest = '%s/docs/source' % os.getcwd() os.system('cd /tmp/recline-docs && %s %s && mv docs/* %s' % (docco_executable,files, dest)) print("** Docs built ok") diff --git a/src/backend/couchdb.js b/src/backend.couchdb.js similarity index 100% rename from src/backend/couchdb.js rename to src/backend.couchdb.js diff --git a/src/backend/csv.js b/src/backend.csv.js similarity index 100% rename from src/backend/csv.js rename to src/backend.csv.js diff --git a/src/backend/dataproxy.js b/src/backend.dataproxy.js similarity index 100% rename from src/backend/dataproxy.js rename to src/backend.dataproxy.js diff --git a/src/backend/elasticsearch.js b/src/backend.elasticsearch.js similarity index 100% rename from src/backend/elasticsearch.js rename to src/backend.elasticsearch.js diff --git a/src/backend/gdocs.js b/src/backend.gdocs.js similarity index 100% rename from src/backend/gdocs.js rename to src/backend.gdocs.js diff --git a/src/backend/memory.js b/src/backend.memory.js similarity index 100% rename from src/backend/memory.js rename to src/backend.memory.js diff --git a/test/backend/csv.test.js b/test/backend.csv.test.js similarity index 100% rename from test/backend/csv.test.js rename to test/backend.csv.test.js diff --git a/test/backend/dataproxy.test.js b/test/backend.dataproxy.test.js similarity index 100% rename from test/backend/dataproxy.test.js rename to test/backend.dataproxy.test.js diff --git a/test/backend/elasticsearch.test.js b/test/backend.elasticsearch.test.js similarity index 100% rename from test/backend/elasticsearch.test.js rename to test/backend.elasticsearch.test.js diff --git a/test/backend/gdocs.test.js b/test/backend.gdocs.test.js similarity index 100% rename from test/backend/gdocs.test.js rename to test/backend.gdocs.test.js diff --git a/test/backend/memory.test.js b/test/backend.memory.test.js similarity index 100% rename from test/backend/memory.test.js rename to test/backend.memory.test.js diff --git a/test/index.html b/test/index.html index db350c70..7a31b6a0 100644 --- a/test/index.html +++ b/test/index.html @@ -29,19 +29,19 @@ - - - - - - + + + + + + - - - - - + + + + + From c1bff3820e1d36ec271bc8e45bf1b644001bc1e8 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 26 Jun 2012 20:41:53 +0100 Subject: [PATCH 07/14] [docs,refactor][xs]: move docco docs to docs/src. --- docs/pycco.css | 190 ------------------ docs/{ => src}/docco.css | 0 docs/{ => src}/model.html | 0 docs/{view-graph.html => src/view.graph.html} | 0 docs/{view-grid.html => src/view.grid.html} | 0 docs/{view-map.html => src/view.map.html} | 0 docs/{view.html => src/view.multiview.html} | 0 7 files changed, 190 deletions(-) delete mode 100644 docs/pycco.css rename docs/{ => src}/docco.css (100%) rename docs/{ => src}/model.html (100%) rename docs/{view-graph.html => src/view.graph.html} (100%) rename docs/{view-grid.html => src/view.grid.html} (100%) rename docs/{view-map.html => src/view.map.html} (100%) rename docs/{view.html => src/view.multiview.html} (100%) diff --git a/docs/pycco.css b/docs/pycco.css deleted file mode 100644 index aef571a5..00000000 --- a/docs/pycco.css +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------- Layout and Typography ----------------------------*/ -body { - font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; - font-size: 16px; - line-height: 24px; - color: #252519; - margin: 0; padding: 0; - background: #f5f5ff; -} -a { - color: #261a3b; -} - a:visited { - color: #261a3b; - } -p { - margin: 0 0 15px 0; -} -h1, h2, h3, h4, h5, h6 { - margin: 40px 0 15px 0; -} -h2, h3, h4, h5, h6 { - margin-top: 0; - } -#container { - background: white; - } -#container, div.section { - position: relative; -} -#background { - position: absolute; - top: 0; left: 580px; right: 0; bottom: 0; - background: #f5f5ff; - border-left: 1px solid #e5e5ee; - z-index: 0; -} -#jump_to, #jump_page { - background: white; - -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; - -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; - font: 10px Arial; - text-transform: uppercase; - cursor: pointer; - text-align: right; -} -#jump_to, #jump_wrapper { - position: fixed; - right: 0; top: 0; - padding: 5px 10px; -} - #jump_wrapper { - padding: 0; - display: none; - } - #jump_to:hover #jump_wrapper { - display: block; - } - #jump_page { - padding: 5px 0 3px; - margin: 0 0 25px 25px; - } - #jump_page .source { - display: block; - padding: 5px 10px; - text-decoration: none; - border-top: 1px solid #eee; - } - #jump_page .source:hover { - background: #f5f5ff; - } - #jump_page .source:first-child { - } -div.docs { - float: left; - max-width: 500px; - min-width: 500px; - min-height: 5px; - padding: 10px 25px 1px 50px; - vertical-align: top; - text-align: left; -} - .docs pre { - margin: 15px 0 15px; - padding-left: 15px; - } - .docs p tt, .docs p code { - background: #f8f8ff; - border: 1px solid #dedede; - font-size: 12px; - padding: 0 0.2em; - } - .octowrap { - position: relative; - } - .octothorpe { - font: 12px Arial; - text-decoration: none; - color: #454545; - position: absolute; - top: 3px; left: -20px; - padding: 1px 2px; - opacity: 0; - -webkit-transition: opacity 0.2s linear; - } - div.docs:hover .octothorpe { - opacity: 1; - } -div.code { - margin-left: 580px; - padding: 14px 15px 16px 50px; - vertical-align: top; -} - .code pre, .docs p code { - font-size: 12px; - } - pre, tt, code { - line-height: 18px; - font-family: Monaco, Consolas, "Lucida Console", monospace; - margin: 0; padding: 0; - } -div.clearall { - clear: both; -} - - -/*---------------------- Syntax Highlighting -----------------------------*/ -td.linenos { background-color: #f0f0f0; padding-right: 10px; } -span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } -body .hll { background-color: #ffffcc } -body .c { color: #408080; font-style: italic } /* Comment */ -body .err { border: 1px solid #FF0000 } /* Error */ -body .k { color: #954121 } /* Keyword */ -body .o { color: #666666 } /* Operator */ -body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ -body .cp { color: #BC7A00 } /* Comment.Preproc */ -body .c1 { color: #408080; font-style: italic } /* Comment.Single */ -body .cs { color: #408080; font-style: italic } /* Comment.Special */ -body .gd { color: #A00000 } /* Generic.Deleted */ -body .ge { font-style: italic } /* Generic.Emph */ -body .gr { color: #FF0000 } /* Generic.Error */ -body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ -body .gi { color: #00A000 } /* Generic.Inserted */ -body .go { color: #808080 } /* Generic.Output */ -body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ -body .gs { font-weight: bold } /* Generic.Strong */ -body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ -body .gt { color: #0040D0 } /* Generic.Traceback */ -body .kc { color: #954121 } /* Keyword.Constant */ -body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ -body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ -body .kp { color: #954121 } /* Keyword.Pseudo */ -body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ -body .kt { color: #B00040 } /* Keyword.Type */ -body .m { color: #666666 } /* Literal.Number */ -body .s { color: #219161 } /* Literal.String */ -body .na { color: #7D9029 } /* Name.Attribute */ -body .nb { color: #954121 } /* Name.Builtin */ -body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ -body .no { color: #880000 } /* Name.Constant */ -body .nd { color: #AA22FF } /* Name.Decorator */ -body .ni { color: #999999; font-weight: bold } /* Name.Entity */ -body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ -body .nf { color: #0000FF } /* Name.Function */ -body .nl { color: #A0A000 } /* Name.Label */ -body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ -body .nt { color: #954121; font-weight: bold } /* Name.Tag */ -body .nv { color: #19469D } /* Name.Variable */ -body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ -body .w { color: #bbbbbb } /* Text.Whitespace */ -body .mf { color: #666666 } /* Literal.Number.Float */ -body .mh { color: #666666 } /* Literal.Number.Hex */ -body .mi { color: #666666 } /* Literal.Number.Integer */ -body .mo { color: #666666 } /* Literal.Number.Oct */ -body .sb { color: #219161 } /* Literal.String.Backtick */ -body .sc { color: #219161 } /* Literal.String.Char */ -body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ -body .s2 { color: #219161 } /* Literal.String.Double */ -body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ -body .sh { color: #219161 } /* Literal.String.Heredoc */ -body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ -body .sx { color: #954121 } /* Literal.String.Other */ -body .sr { color: #BB6688 } /* Literal.String.Regex */ -body .s1 { color: #219161 } /* Literal.String.Single */ -body .ss { color: #19469D } /* Literal.String.Symbol */ -body .bp { color: #954121 } /* Name.Builtin.Pseudo */ -body .vc { color: #19469D } /* Name.Variable.Class */ -body .vg { color: #19469D } /* Name.Variable.Global */ -body .vi { color: #19469D } /* Name.Variable.Instance */ -body .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/docs/docco.css b/docs/src/docco.css similarity index 100% rename from docs/docco.css rename to docs/src/docco.css diff --git a/docs/model.html b/docs/src/model.html similarity index 100% rename from docs/model.html rename to docs/src/model.html diff --git a/docs/view-graph.html b/docs/src/view.graph.html similarity index 100% rename from docs/view-graph.html rename to docs/src/view.graph.html diff --git a/docs/view-grid.html b/docs/src/view.grid.html similarity index 100% rename from docs/view-grid.html rename to docs/src/view.grid.html diff --git a/docs/view-map.html b/docs/src/view.map.html similarity index 100% rename from docs/view-map.html rename to docs/src/view.map.html diff --git a/docs/view.html b/docs/src/view.multiview.html similarity index 100% rename from docs/view.html rename to docs/src/view.multiview.html From 11b37e92f7258857abd661277264cfd467b5bdd7 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 26 Jun 2012 20:50:06 +0100 Subject: [PATCH 08/14] [docs/src][s]: build latest. --- docs/backend/base.html | 97 --- docs/backend/dataproxy.html | 108 ---- docs/backend/docco.css | 186 ------ docs/backend/elasticsearch.html | 193 ------ docs/backend/gdocs.html | 149 ----- docs/backend/pycco.css | 190 ------ docs/src/backend.couchdb.html | 429 +++++++++++++ .../csv.html => src/backend.csv.html} | 114 ++-- docs/src/backend.dataproxy.html | 63 ++ docs/src/backend.elasticsearch.html | 225 +++++++ docs/src/backend.gdocs.html | 108 ++++ .../memory.html => src/backend.memory.html} | 128 ++-- docs/src/model.html | 506 ++++++++------- docs/src/view.graph.html | 265 ++++---- docs/src/view.grid.html | 18 +- docs/src/view.map.html | 592 ++++++++++-------- docs/src/view.multiview.html | 483 ++++---------- make | 2 +- 18 files changed, 1785 insertions(+), 2071 deletions(-) delete mode 100644 docs/backend/base.html delete mode 100644 docs/backend/dataproxy.html delete mode 100644 docs/backend/docco.css delete mode 100644 docs/backend/elasticsearch.html delete mode 100644 docs/backend/gdocs.html delete mode 100644 docs/backend/pycco.css create mode 100644 docs/src/backend.couchdb.html rename docs/{backend/csv.html => src/backend.csv.html} (55%) create mode 100644 docs/src/backend.dataproxy.html create mode 100644 docs/src/backend.elasticsearch.html create mode 100644 docs/src/backend.gdocs.html rename docs/{backend/memory.html => src/backend.memory.html} (59%) diff --git a/docs/backend/base.html b/docs/backend/base.html deleted file mode 100644 index f1188f39..00000000 --- a/docs/backend/base.html +++ /dev/null @@ -1,97 +0,0 @@ - base.js

base.js

Recline Backends

- -

Backends are connectors to backend data sources and stores

- -

This is just the base module containing a template Base class and convenience methods.

this.recline = this.recline || {};
-this.recline.Backend = this.recline.Backend || {};

recline.Backend.Base

- -

Exemplar 'class' for backends showing what a base class would look like.

this.recline.Backend.Base = function() {

type

- -

'type' of this backend. This should be either the class path for this -object as a string (e.g. recline.Backend.Memory) or for Backends within -recline.Backend module it may be their class name.

- -

This value is used as an identifier for this backend when initializing -backends (see recline.Model.Dataset.initialize).

  this.__type__ = 'base';

readonly

- -

Class level attribute indicating that this backend is read-only (that -is, cannot be written to).

  this.readonly = true;

sync

- -

An implementation of Backbone.sync that will be used to override -Backbone.sync on operations for Datasets and Documents which are using this backend.

- -

For read-only implementations you will need only to implement read method -for Dataset models (and even this can be a null operation). The read method -should return relevant metadata for the Dataset. We do not require read support -for Documents because they are loaded in bulk by the query method.

- -

For backends supporting write operations you must implement update and delete support for Document objects.

- -

All code paths should return an object conforming to the jquery promise API.

  this.sync = function(method, model, options) {
-  },
-  

query

- -

Query the backend for documents returning them in bulk. This method will -be used by the Dataset.query method to search the backend for documents, -retrieving the results in bulk.

- -

@param {recline.model.Dataset} model: Dataset model.

- -

@param {Object} queryObj: object describing a query (usually produced by -using recline.Model.Query and calling toJSON on it).

- -

The structure of data in the Query object or -Hash should follow that defined in issue 34. -(Of course, if you are writing your own backend, and hence -have control over the interpretation of the query object, you -can use whatever structure you like).

- -

@returns {Promise} promise API object. The promise resolve method will -be called on query completion with a QueryResult object.

- -

A QueryResult has the following structure (modelled closely on -ElasticSearch - see this issue for more -details):

- -
-{
-  total: // (required) total number of results (can be null)
-  hits: [ // (required) one entry for each result document
-    {
-       _score:   // (optional) match score for document
-       _type: // (optional) document type
-       _source: // (required) document/row object
-    } 
-  ],
-  facets: { // (optional) 
-    // facet results (as per )
-  }
-}
-
  this.query = function(model, queryObj) {}
-};

makeRequest

- -

Just $.ajax but in any headers in the 'headers' attribute of this -Backend instance. Example:

- -
-var jqxhr = this._makeRequest({
-  url: the-url
-});
-
this.recline.Backend.makeRequest = function(data, headers) {
-  var extras = {};
-  if (headers) {
-    extras = {
-      beforeSend: function(req) {
-        _.each(headers, function(value, key) {
-          req.setRequestHeader(key, value);
-        });
-      }
-    };
-  }
-  var data = _.extend(extras, data);
-  return $.ajax(data);
-};
-
-
\ No newline at end of file diff --git a/docs/backend/dataproxy.html b/docs/backend/dataproxy.html deleted file mode 100644 index e5229396..00000000 --- a/docs/backend/dataproxy.html +++ /dev/null @@ -1,108 +0,0 @@ - dataproxy.js

dataproxy.js

this.recline = this.recline || {};
-this.recline.Backend = this.recline.Backend || {};
-this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
-
-(function($, my) {

DataProxy Backend

- -

For connecting to DataProxy-s.

- -

When initializing the DataProxy backend you can set the following -attributes in the options object:

- -
    -
  • dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com
  • -
- -

Datasets using using this backend should set the following attributes:

- -
    -
  • url: (required) url-of-data-to-proxy
  • -
  • format: (optional) csv | xls (defaults to csv if not specified)
  • -
- -

Note that this is a read-only backend.

  my.Backbone = function(options) {
-    var self = this;
-    this.__type__ = 'dataproxy';
-    this.readonly = true;
-
-    this.dataproxy_url = options && options.dataproxy_url ? options.dataproxy_url : 'http://jsonpdataproxy.appspot.com';
-
-    this.sync = function(method, model, options) {
-      if (method === "read") {
-        if (model.__type__ == 'Dataset') {

Do nothing as we will get fields in query step (and no metadata to -retrieve)

          var dfd = $.Deferred();
-          dfd.resolve(model);
-          return dfd.promise();
-        }
-      } else {
-        alert('This backend only supports read operations');
-      }
-    };
-
-    this.query = function(dataset, queryObj) {
-      var self = this;
-      var data = {
-        url: dataset.get('url'),
-        'max-results':  queryObj.size,
-        type: dataset.get('format')
-      };
-      var jqxhr = $.ajax({
-        url: this.dataproxy_url,
-        data: data,
-        dataType: 'jsonp'
-      });
-      var dfd = $.Deferred();
-      _wrapInTimeout(jqxhr).done(function(results) {
-        if (results.error) {
-          dfd.reject(results.error);
-        }
-        dataset.fields.reset(_.map(results.fields, function(fieldId) {
-          return {id: fieldId};
-          })
-        );
-        var _out = _.map(results.data, function(doc) {
-          var tmp = {};
-          _.each(results.fields, function(key, idx) {
-            tmp[key] = doc[idx];
-          });
-          return tmp;
-        });
-        dfd.resolve({
-          total: null,
-          hits: _.map(_out, function(row) {
-            return { _source: row };
-          })
-        });
-      })
-      .fail(function(arguments) {
-        dfd.reject(arguments);
-      });
-      return dfd.promise();
-    };
-  };

_wrapInTimeout

- -

Convenience method providing a crude way to catch backend errors on JSONP calls. -Many of backends use JSONP and so will not get error messages and this is -a crude way to catch those errors.

  var _wrapInTimeout = function(ourFunction) {
-    var dfd = $.Deferred();
-    var timeout = 5000;
-    var timer = setTimeout(function() {
-      dfd.reject({
-        message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
-      });
-    }, timeout);
-    ourFunction.done(function(arguments) {
-        clearTimeout(timer);
-        dfd.resolve(arguments);
-      })
-      .fail(function(arguments) {
-        clearTimeout(timer);
-        dfd.reject(arguments);
-      })
-      ;
-    return dfd.promise();
-  }
-
-}(jQuery, this.recline.Backend.DataProxy));
-
-
\ No newline at end of file diff --git a/docs/backend/docco.css b/docs/backend/docco.css deleted file mode 100644 index 5aa0a8d7..00000000 --- a/docs/backend/docco.css +++ /dev/null @@ -1,186 +0,0 @@ -/*--------------------- Layout and Typography ----------------------------*/ -body { - font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; - font-size: 15px; - line-height: 22px; - color: #252519; - margin: 0; padding: 0; -} -a { - color: #261a3b; -} - a:visited { - color: #261a3b; - } -p { - margin: 0 0 15px 0; -} -h1, h2, h3, h4, h5, h6 { - margin: 0px 0 15px 0; -} - h1 { - margin-top: 40px; - } -#container { - position: relative; -} -#background { - position: fixed; - top: 0; left: 525px; right: 0; bottom: 0; - background: #f5f5ff; - border-left: 1px solid #e5e5ee; - z-index: -1; -} -#jump_to, #jump_page { - background: white; - -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; - -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; - font: 10px Arial; - text-transform: uppercase; - cursor: pointer; - text-align: right; -} -#jump_to, #jump_wrapper { - position: fixed; - right: 0; top: 0; - padding: 5px 10px; -} - #jump_wrapper { - padding: 0; - display: none; - } - #jump_to:hover #jump_wrapper { - display: block; - } - #jump_page { - padding: 5px 0 3px; - margin: 0 0 25px 25px; - } - #jump_page .source { - display: block; - padding: 5px 10px; - text-decoration: none; - border-top: 1px solid #eee; - } - #jump_page .source:hover { - background: #f5f5ff; - } - #jump_page .source:first-child { - } -table td { - border: 0; - outline: 0; -} - td.docs, th.docs { - max-width: 450px; - min-width: 450px; - min-height: 5px; - padding: 10px 25px 1px 50px; - overflow-x: hidden; - vertical-align: top; - text-align: left; - } - .docs pre { - margin: 15px 0 15px; - padding-left: 15px; - } - .docs p tt, .docs p code { - background: #f8f8ff; - border: 1px solid #dedede; - font-size: 12px; - padding: 0 0.2em; - } - .pilwrap { - position: relative; - } - .pilcrow { - font: 12px Arial; - text-decoration: none; - color: #454545; - position: absolute; - top: 3px; left: -20px; - padding: 1px 2px; - opacity: 0; - -webkit-transition: opacity 0.2s linear; - } - td.docs:hover .pilcrow { - opacity: 1; - } - td.code, th.code { - padding: 14px 15px 16px 25px; - width: 100%; - vertical-align: top; - background: #f5f5ff; - border-left: 1px solid #e5e5ee; - } - pre, tt, code { - font-size: 12px; line-height: 18px; - font-family: Monaco, Consolas, "Lucida Console", monospace; - margin: 0; padding: 0; - } - - -/*---------------------- Syntax Highlighting -----------------------------*/ -td.linenos { background-color: #f0f0f0; padding-right: 10px; } -span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } -body .hll { background-color: #ffffcc } -body .c { color: #408080; font-style: italic } /* Comment */ -body .err { border: 1px solid #FF0000 } /* Error */ -body .k { color: #954121 } /* Keyword */ -body .o { color: #666666 } /* Operator */ -body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ -body .cp { color: #BC7A00 } /* Comment.Preproc */ -body .c1 { color: #408080; font-style: italic } /* Comment.Single */ -body .cs { color: #408080; font-style: italic } /* Comment.Special */ -body .gd { color: #A00000 } /* Generic.Deleted */ -body .ge { font-style: italic } /* Generic.Emph */ -body .gr { color: #FF0000 } /* Generic.Error */ -body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ -body .gi { color: #00A000 } /* Generic.Inserted */ -body .go { color: #808080 } /* Generic.Output */ -body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ -body .gs { font-weight: bold } /* Generic.Strong */ -body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ -body .gt { color: #0040D0 } /* Generic.Traceback */ -body .kc { color: #954121 } /* Keyword.Constant */ -body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ -body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ -body .kp { color: #954121 } /* Keyword.Pseudo */ -body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ -body .kt { color: #B00040 } /* Keyword.Type */ -body .m { color: #666666 } /* Literal.Number */ -body .s { color: #219161 } /* Literal.String */ -body .na { color: #7D9029 } /* Name.Attribute */ -body .nb { color: #954121 } /* Name.Builtin */ -body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ -body .no { color: #880000 } /* Name.Constant */ -body .nd { color: #AA22FF } /* Name.Decorator */ -body .ni { color: #999999; font-weight: bold } /* Name.Entity */ -body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ -body .nf { color: #0000FF } /* Name.Function */ -body .nl { color: #A0A000 } /* Name.Label */ -body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ -body .nt { color: #954121; font-weight: bold } /* Name.Tag */ -body .nv { color: #19469D } /* Name.Variable */ -body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ -body .w { color: #bbbbbb } /* Text.Whitespace */ -body .mf { color: #666666 } /* Literal.Number.Float */ -body .mh { color: #666666 } /* Literal.Number.Hex */ -body .mi { color: #666666 } /* Literal.Number.Integer */ -body .mo { color: #666666 } /* Literal.Number.Oct */ -body .sb { color: #219161 } /* Literal.String.Backtick */ -body .sc { color: #219161 } /* Literal.String.Char */ -body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ -body .s2 { color: #219161 } /* Literal.String.Double */ -body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ -body .sh { color: #219161 } /* Literal.String.Heredoc */ -body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ -body .sx { color: #954121 } /* Literal.String.Other */ -body .sr { color: #BB6688 } /* Literal.String.Regex */ -body .s1 { color: #219161 } /* Literal.String.Single */ -body .ss { color: #19469D } /* Literal.String.Symbol */ -body .bp { color: #954121 } /* Name.Builtin.Pseudo */ -body .vc { color: #19469D } /* Name.Variable.Class */ -body .vg { color: #19469D } /* Name.Variable.Global */ -body .vi { color: #19469D } /* Name.Variable.Instance */ -body .il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/backend/elasticsearch.html b/docs/backend/elasticsearch.html deleted file mode 100644 index ffd2f047..00000000 --- a/docs/backend/elasticsearch.html +++ /dev/null @@ -1,193 +0,0 @@ - elasticsearch.js

elasticsearch.js

this.recline = this.recline || {};
-this.recline.Backend = this.recline.Backend || {};
-this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {};
-
-(function($, my) {

ElasticSearch Wrapper

- -

Connecting to ElasticSearch endpoints. -@param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running -on localhost:9200 with index // twitter and type tweet it would be:

- -
http://localhost:9200/twitter/tweet
- -

@param {Object} options: set of options such as:

- -
    -
  • headers - {dict of headers to add to each request}
  • -
  • dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests)
  • -
  my.Wrapper = function(endpoint, options) { 
-    var self = this;
-    this.endpoint = endpoint;
-    this.options = _.extend({
-        dataType: 'json'
-      },
-      options);

mapping

- -

Get ES mapping for this type/table

- -

@return promise compatible deferred object.

    this.mapping = function() {
-      var schemaUrl = self.endpoint + '/_mapping';
-      var jqxhr = recline.Backend.makeRequest({
-        url: schemaUrl,
-        dataType: this.options.dataType
-      });
-      return jqxhr;
-    };

get

- -

Get document corresponding to specified id

- -

@return promise compatible deferred object.

    this.get = function(id) {
-      var base = this.endpoint + '/' + id;
-      return recline.Backend.makeRequest({
-        url: base,
-        dataType: 'json'
-      });
-    };

upsert

- -

create / update a document to ElasticSearch backend

- -

@param {Object} doc an object to insert to the index. -@return deferred supporting promise API

    this.upsert = function(doc) {
-      var data = JSON.stringify(doc);
-      url = this.endpoint;
-      if (doc.id) {
-        url += '/' + doc.id;
-      }
-      return recline.Backend.makeRequest({
-        url: url,
-        type: 'POST',
-        data: data,
-        dataType: 'json'
-      });
-    };

delete

- -

Delete a document from the ElasticSearch backend.

- -

@param {Object} id id of object to delete -@return deferred supporting promise API

    this.delete = function(id) {
-      url = this.endpoint;
-      url += '/' + id;
-      return recline.Backend.makeRequest({
-        url: url,
-        type: 'DELETE',
-        dataType: 'json'
-      });
-    };
-
-    this._normalizeQuery = function(queryObj) {
-      var out = queryObj && queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
-      if (out.q !== undefined && out.q.trim() === '') {
-        delete out.q;
-      }
-      if (!out.q) {
-        out.query = {
-          match_all: {}
-        };
-      } else {
-        out.query = {
-          query_string: {
-            query: out.q
-          }
-        };
-        delete out.q;
-      }

now do filters (note the plural)

      if (out.filters && out.filters.length) {
-        if (!out.filter) {
-          out.filter = {};
-        }
-        if (!out.filter.and) {
-          out.filter.and = [];
-        }
-        out.filter.and = out.filter.and.concat(out.filters);
-      }
-      if (out.filters !== undefined) {
-        delete out.filters;
-      }
-      return out;
-    };

query

- -

@return deferred supporting promise API

    this.query = function(queryObj) {
-      var queryNormalized = this._normalizeQuery(queryObj);
-      var data = {source: JSON.stringify(queryNormalized)};
-      var url = this.endpoint + '/_search';
-      var jqxhr = recline.Backend.makeRequest({
-        url: url,
-        data: data,
-        dataType: this.options.dataType
-      });
-      return jqxhr;
-    }
-  };

ElasticSearch Backbone Backend

- -

Backbone connector for an ES backend.

- -

Usage:

- -

var backend = new recline.Backend.ElasticSearch(options);

- -

options are passed through to Wrapper

  my.Backbone = function(options) {
-    var self = this;
-    var esOptions = options;
-    this.__type__ = 'elasticsearch';

sync

- -

Backbone sync implementation for this backend.

- -

URL of ElasticSearch endpoint to use must be specified on the dataset -(and on a Document via its dataset attribute) by the dataset having a -url attribute.

    this.sync = function(method, model, options) {
-      if (model.__type__ == 'Dataset') {
-        var endpoint = model.get('url');
-      } else {
-        var endpoint = model.dataset.get('url');
-      }
-      var es = new my.Wrapper(endpoint, esOptions);
-      if (method === "read") {
-        if (model.__type__ == 'Dataset') {
-          var dfd = $.Deferred();
-          es.mapping().done(function(schema) {

only one top level key in ES = the type so we can ignore it

            var key = _.keys(schema)[0];
-            var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
-              dict.id = fieldName;
-              return dict;
-            });
-            model.fields.reset(fieldData);
-            dfd.resolve(model);
-          })
-          .fail(function(arguments) {
-            dfd.reject(arguments);
-          });
-          return dfd.promise();
-        } else if (model.__type__ == 'Document') {
-          return es.get(model.dataset.id);
-        }
-      } else if (method === 'update') {
-        if (model.__type__ == 'Document') {
-          return es.upsert(model.toJSON());
-        }
-      } else if (method === 'delete') {
-        if (model.__type__ == 'Document') {
-          return es.delete(model.id);
-        }
-      }
-    };

query

- -

query the ES backend

    this.query = function(model, queryObj) {
-      var dfd = $.Deferred();
-      var url = model.get('url');
-      var es = new my.Wrapper(url, esOptions);
-      var jqxhr = es.query(queryObj);

TODO: fail case

      jqxhr.done(function(results) {
-        _.each(results.hits.hits, function(hit) {
-          if (!('id' in hit._source) && hit._id) {
-            hit._source.id = hit._id;
-          }
-        });
-        if (results.facets) {
-          results.hits.facets = results.facets;
-        }
-        dfd.resolve(results.hits);
-      });
-      return dfd.promise();
-    };
-  };
-
-}(jQuery, this.recline.Backend.ElasticSearch));
-
-
\ No newline at end of file diff --git a/docs/backend/gdocs.html b/docs/backend/gdocs.html deleted file mode 100644 index e014d57d..00000000 --- a/docs/backend/gdocs.html +++ /dev/null @@ -1,149 +0,0 @@ - gdocs.js

gdocs.js

this.recline = this.recline || {};
-this.recline.Backend = this.recline.Backend || {};
-this.recline.Backend.GDocs = this.recline.Backend.GDocs || {};
-
-(function($, my) {

Google spreadsheet backend

- -

Connect to Google Docs spreadsheet.

- -

Dataset must have a url attribute pointing to the Gdocs -spreadsheet's JSON feed e.g.

- -
-var dataset = new recline.Model.Dataset({
-    url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
-  },
-  'gdocs'
-);
-
  my.Backbone = function() {
-    var self = this;
-    this.__type__ = 'gdocs';
-    this.readonly = true;
-
-    this.sync = function(method, model, options) {
-      var self = this;
-      if (method === "read") { 
-        var dfd = $.Deferred(); 
-        dfd.resolve(model);
-        return dfd.promise();
-      }
-    };
-
-    this.query = function(dataset, queryObj) { 
-      var dfd = $.Deferred();
-      if (dataset._dataCache) {
-        dfd.resolve(dataset._dataCache);
-      } else {
-        loadData(dataset.get('url')).done(function(result) {
-          dataset.fields.reset(result.fields);

cache data onto dataset (we have loaded whole gdoc it seems!)

          dataset._dataCache = self._formatResults(dataset, result.data);
-          dfd.resolve(dataset._dataCache);
-        });
-      }
-      return dfd.promise();
-    };
-
-    this._formatResults = function(dataset, data) {
-      var fields = _.pluck(dataset.fields.toJSON(), 'id');

zip the fields with the data rows to produce js objs -TODO: factor this out as a common method with other backends

      var objs = _.map(data, function (d) { 
-        var obj = {};
-        _.each(_.zip(fields, d), function (x) {
-          obj[x[0]] = x[1];
-        });
-        return obj;
-      });
-      var out = {
-        total: objs.length,
-        hits: _.map(objs, function(row) {
-          return { _source: row }
-        })
-      }
-      return out;
-    };
-  };

loadData

- -

loadData from a google docs URL

- -

@return object with two attributes

- -
    -
  • fields: array of objects
  • -
  • data: array of arrays
  • -
  var loadData = function(url) {
-    var dfd = $.Deferred(); 
-    var url = my.getSpreadsheetAPIUrl(url);
-    var out = {
-      fields: [],
-      data: []
-    }
-    $.getJSON(url, function(d) {
-      result = my.parseData(d);
-      result.fields = _.map(result.fields, function(fieldId) {
-        return {id: fieldId};
-      });
-      dfd.resolve(result);
-    });
-    return dfd.promise();
-  };

parseData

- -

Parse data from Google Docs API into a reasonable form

- -

:options: (optional) optional argument dictionary: -columnsToUse: list of columns to use (specified by field names) -colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). -:return: tabular data object (hash with keys: field and data).

- -

Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.

  my.parseData = function(gdocsSpreadsheet) {
-    var options = {};
-    if (arguments.length > 1) {
-      options = arguments[1];
-    }
-    var results = {
-      'fields': [],
-      'data': []
-    };

default is no special info on type of columns

    var colTypes = {};
-    if (options.colTypes) {
-      colTypes = options.colTypes;
-    }
-    if (gdocsSpreadsheet.feed.entry.length > 0) {
-      for (var k in gdocsSpreadsheet.feed.entry[0]) {
-        if (k.substr(0, 3) == 'gsx') {
-          var col = k.substr(4);
-          results.fields.push(col);
-        }
-      }
-    }

converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])

    var rep = /^([\d\.\-]+)\%$/;
-    $.each(gdocsSpreadsheet.feed.entry, function (i, entry) {
-      var row = [];
-      for (var k in results.fields) {
-        var col = results.fields[k];
-        var _keyname = 'gsx$' + col;
-        var value = entry[_keyname]['$t'];

if labelled as % and value contains %, convert

        if (colTypes[col] == 'percent') {
-          if (rep.test(value)) {
-            var value2 = rep.exec(value);
-            var value3 = parseFloat(value2);
-            value = value3 / 100;
-          }
-        }
-        row.push(value);
-      }
-      results.data.push(row);
-    });
-    return results;
-  };

Convenience function to get GDocs JSON API Url from standard URL

  my.getSpreadsheetAPIUrl = function(url) {
-    if (url.indexOf('feeds/list') != -1) {
-      return url;
-    } else {

https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0

      var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
-      var matches = url.match(regex);
-      if (matches) {
-        var key = matches[1];
-        var worksheet = 1;
-        var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json';
-        return out;
-      } else {
-        alert('Failed to extract gdocs key from ' + url);
-      }
-    }
-  };
-}(jQuery, this.recline.Backend.GDocs));
-
-
\ No newline at end of file diff --git a/docs/backend/pycco.css b/docs/backend/pycco.css deleted file mode 100644 index aef571a5..00000000 --- a/docs/backend/pycco.css +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------- Layout and Typography ----------------------------*/ -body { - font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; - font-size: 16px; - line-height: 24px; - color: #252519; - margin: 0; padding: 0; - background: #f5f5ff; -} -a { - color: #261a3b; -} - a:visited { - color: #261a3b; - } -p { - margin: 0 0 15px 0; -} -h1, h2, h3, h4, h5, h6 { - margin: 40px 0 15px 0; -} -h2, h3, h4, h5, h6 { - margin-top: 0; - } -#container { - background: white; - } -#container, div.section { - position: relative; -} -#background { - position: absolute; - top: 0; left: 580px; right: 0; bottom: 0; - background: #f5f5ff; - border-left: 1px solid #e5e5ee; - z-index: 0; -} -#jump_to, #jump_page { - background: white; - -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; - -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; - font: 10px Arial; - text-transform: uppercase; - cursor: pointer; - text-align: right; -} -#jump_to, #jump_wrapper { - position: fixed; - right: 0; top: 0; - padding: 5px 10px; -} - #jump_wrapper { - padding: 0; - display: none; - } - #jump_to:hover #jump_wrapper { - display: block; - } - #jump_page { - padding: 5px 0 3px; - margin: 0 0 25px 25px; - } - #jump_page .source { - display: block; - padding: 5px 10px; - text-decoration: none; - border-top: 1px solid #eee; - } - #jump_page .source:hover { - background: #f5f5ff; - } - #jump_page .source:first-child { - } -div.docs { - float: left; - max-width: 500px; - min-width: 500px; - min-height: 5px; - padding: 10px 25px 1px 50px; - vertical-align: top; - text-align: left; -} - .docs pre { - margin: 15px 0 15px; - padding-left: 15px; - } - .docs p tt, .docs p code { - background: #f8f8ff; - border: 1px solid #dedede; - font-size: 12px; - padding: 0 0.2em; - } - .octowrap { - position: relative; - } - .octothorpe { - font: 12px Arial; - text-decoration: none; - color: #454545; - position: absolute; - top: 3px; left: -20px; - padding: 1px 2px; - opacity: 0; - -webkit-transition: opacity 0.2s linear; - } - div.docs:hover .octothorpe { - opacity: 1; - } -div.code { - margin-left: 580px; - padding: 14px 15px 16px 50px; - vertical-align: top; -} - .code pre, .docs p code { - font-size: 12px; - } - pre, tt, code { - line-height: 18px; - font-family: Monaco, Consolas, "Lucida Console", monospace; - margin: 0; padding: 0; - } -div.clearall { - clear: both; -} - - -/*---------------------- Syntax Highlighting -----------------------------*/ -td.linenos { background-color: #f0f0f0; padding-right: 10px; } -span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } -body .hll { background-color: #ffffcc } -body .c { color: #408080; font-style: italic } /* Comment */ -body .err { border: 1px solid #FF0000 } /* Error */ -body .k { color: #954121 } /* Keyword */ -body .o { color: #666666 } /* Operator */ -body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ -body .cp { color: #BC7A00 } /* Comment.Preproc */ -body .c1 { color: #408080; font-style: italic } /* Comment.Single */ -body .cs { color: #408080; font-style: italic } /* Comment.Special */ -body .gd { color: #A00000 } /* Generic.Deleted */ -body .ge { font-style: italic } /* Generic.Emph */ -body .gr { color: #FF0000 } /* Generic.Error */ -body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ -body .gi { color: #00A000 } /* Generic.Inserted */ -body .go { color: #808080 } /* Generic.Output */ -body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ -body .gs { font-weight: bold } /* Generic.Strong */ -body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ -body .gt { color: #0040D0 } /* Generic.Traceback */ -body .kc { color: #954121 } /* Keyword.Constant */ -body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ -body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ -body .kp { color: #954121 } /* Keyword.Pseudo */ -body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ -body .kt { color: #B00040 } /* Keyword.Type */ -body .m { color: #666666 } /* Literal.Number */ -body .s { color: #219161 } /* Literal.String */ -body .na { color: #7D9029 } /* Name.Attribute */ -body .nb { color: #954121 } /* Name.Builtin */ -body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ -body .no { color: #880000 } /* Name.Constant */ -body .nd { color: #AA22FF } /* Name.Decorator */ -body .ni { color: #999999; font-weight: bold } /* Name.Entity */ -body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ -body .nf { color: #0000FF } /* Name.Function */ -body .nl { color: #A0A000 } /* Name.Label */ -body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ -body .nt { color: #954121; font-weight: bold } /* Name.Tag */ -body .nv { color: #19469D } /* Name.Variable */ -body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ -body .w { color: #bbbbbb } /* Text.Whitespace */ -body .mf { color: #666666 } /* Literal.Number.Float */ -body .mh { color: #666666 } /* Literal.Number.Hex */ -body .mi { color: #666666 } /* Literal.Number.Integer */ -body .mo { color: #666666 } /* Literal.Number.Oct */ -body .sb { color: #219161 } /* Literal.String.Backtick */ -body .sc { color: #219161 } /* Literal.String.Char */ -body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ -body .s2 { color: #219161 } /* Literal.String.Double */ -body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ -body .sh { color: #219161 } /* Literal.String.Heredoc */ -body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ -body .sx { color: #954121 } /* Literal.String.Other */ -body .sr { color: #BB6688 } /* Literal.String.Regex */ -body .s1 { color: #219161 } /* Literal.String.Single */ -body .ss { color: #19469D } /* Literal.String.Symbol */ -body .bp { color: #954121 } /* Name.Builtin.Pseudo */ -body .vc { color: #19469D } /* Name.Variable.Class */ -body .vg { color: #19469D } /* Name.Variable.Global */ -body .vi { color: #19469D } /* Name.Variable.Instance */ -body .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/docs/src/backend.couchdb.html b/docs/src/backend.couchdb.html new file mode 100644 index 00000000..bafff34e --- /dev/null +++ b/docs/src/backend.couchdb.html @@ -0,0 +1,429 @@ + backend.couchdb.js

backend.couchdb.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {};
+
+(function($, my) {
+  my.__type__ = 'couchdb';

CouchDB Wrapper

+ +

Connecting to [CouchDB] (http://www.couchdb.apache.org/) endpoints. +@param {String} endpoint: url for CouchDB database, e.g. for Couchdb running +on localhost:5984 with database // ckan-std it would be:

+ +
http://localhost:5984/ckan-std
+ +

TODO Add user/password arguments for couchdb authentication support.

  my.CouchDBWrapper = function(db_url, view_url, options) { 
+    var self = this;
+    self.endpoint = db_url;
+    self.view_url = (view_url) ? view_url : db_url+'/'+'_all_docs';
+    self.options = _.extend({
+        dataType: 'json'
+      },
+      options);
+
+    this._makeRequest = function(data, headers) {
+      var extras = {};
+      if (headers) {
+        extras = {
+          beforeSend: function(req) {
+            _.each(headers, function(value, key) {
+              req.setRequestHeader(key, value);
+            });
+          }
+        };
+      }
+      data = _.extend(extras, data);
+      return $.ajax(data);
+    };

mapping

+ +

Get mapping for this database. +Assume all docs in the view have the same schema so +limit query to single result.

+ +

@return promise compatible deferred object.

    this.mapping = function() {
+      var schemaUrl = self.view_url + '?limit=1&include_docs=true';
+      var jqxhr = self._makeRequest({
+        url: schemaUrl,
+        dataType: self.options.dataType
+      });
+      return jqxhr;
+    };

get

+ +

Get record corresponding to specified id

+ +

@return promise compatible deferred object.

    this.get = function(_id) {
+      var base = self.endpoint + '/' + _id;
+      return self._makeRequest({
+        url: base,
+        dataType: 'json'
+      });
+    };

upsert

+ +

create / update a record to CouchDB backend

+ +

@param {Object} doc an object to insert to the index. +@return deferred supporting promise API

    this.upsert = function(doc) {
+      var data = JSON.stringify(doc);
+      url = self.endpoint;
+      if (doc._id) {
+        url += '/' + doc._id;
+      }

use a PUT, not a POST to update the document: +http://wiki.apache.org/couchdb/HTTPDocumentAPI#POST

      return self._makeRequest({
+        url: url,
+        type: 'PUT',
+        data: data,
+        dataType: 'json',
+        contentType: 'application/json'
+      });
+    };

delete

+ +

Delete a record from the CouchDB backend.

+ +

@param {Object} id id of object to delete +@return deferred supporting promise API

    this.delete = function(_id) {
+      url = self.endpoint;
+      url += '/' + _id;
+      return self._makeRequest({
+        url: url,
+        type: 'DELETE',
+        dataType: 'json'
+      });
+    };

_normalizeQuery

+ +

Convert the query object from Elastic Search format to a +Couchdb View API compatible format. +See: http://wiki.apache.org/couchdb/HTTPviewAPI

    this._normalizeQuery = function(queryObj) {
+      var out = queryObj && queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
+      delete out.sort;
+      delete out.query;
+      delete out.filters;
+      delete out.fields;
+      delete out.facets;
+      out['skip'] = out.from || 0;
+      out['limit'] = out.size || 100;
+      delete out.from;
+      delete out.size;
+      out['include_docs'] = true;      
+      return out;
+    };

query

+ +

@param {Object} recline.Query instance. +@param {Object} additional couchdb view query options. +@return deferred supporting promise API

    this.query = function(query_object, query_options) {
+      var norm_q = self._normalizeQuery(query_object);
+      var url = self.view_url;
+      var q = _.extend(query_options, norm_q);
+
+      var jqxhr = self._makeRequest({
+        url: url,
+        data: JSON.stringify(q),
+        dataType: self.options.dataType,
+      });
+      return jqxhr;
+    }
+  };

CouchDB Backend

+ +

Backbone connector for a CouchDB backend.

+ +

Usage:

+ +

var backend = new recline.Backend.CouchDB(); +var dataset = new recline.Model.Dataset({ + dburl: '/couchdb/mydb', + viewurl: '/couchdb/mydb/design/design1/views/view1', + queryoptions: { + 'key': 'somedocument_key' + } +}); +backend.fetch(dataset.toJSON()); +backend.query(query, dataset.toJSON()).done(function () { ... });

+ +

Alternatively: +var dataset = new recline.Model.Dataset({ ... }, 'couchdb'); +dataset.fetch(); +var results = dataset.query(query_obj);

+ +

Additionally, the Dataset instance may define three methods: + function recordupdate (record, document) { ... } + function recorddelete (record, document) { ... } + function recordcreate (record, document) { ... } +Where record is the JSON representation of the Record/Document instance +and document is the JSON document stored in couchdb. +When _alldocs view is used (default), a record is the same as a document +so these methods need not be defined. +They are most useful when using a custom view that performs a map-reduce +operation on each document to yield a record. Hence, when the record is +created, updated or deleted, an inverse operation must be performed on the original +document.

+ +

@param {string} url of couchdb database. +@param {string} (optional) url of couchdb view. default:db_url/alldocs +@param {Object} (optional) query options accepted by couchdb views.

  my.couchOptions = {};

fetch

+ +

@param {object} dataset json object with the dburl, viewurl, and query_options args. +@return promise object that resolves to the document mapping.

  my.fetch = function (dataset) {
+    var db_url    = dataset.db_url;
+    var view_url  = dataset.view_url;
+    var cdb       = new my.CouchDBWrapper(db_url, view_url);
+    var dfd       = $.Deferred();

if 'doc' attribute is present, return schema of that +else return schema of 'value' attribute which contains +the map-reduced document.

    cdb.mapping().done(function(result) {
+      var row = result.rows[0];
+      var keys = [];
+      if (view_url.search("_all_docs") !== -1) {
+        keys = _.keys(row['doc']);
+        keys = _.filter(keys, function (k) { return k.charAt(0) !== '_' });
+      }
+      else {
+        keys = _.keys(row['value']);
+      }
+
+      var fieldData = _.map(keys, function(k) {
+        return { 'id' : k };
+      });     
+      dfd.resolve({
+        fields: fieldData
+      });
+    })
+    .fail(function(arguments) {
+      dfd.reject(arguments);
+    });
+    return dfd.promise();
+  };

save

+ +

Iterate through all the changes and save them to the server. +N.B. This method is asynchronous and attempts to do multiple +operation concurrently. This can be problematic when more than +one operation is requested on the same document (as in the case +of bulk column transforms).

+ +

@param {object} lists of create, update, delete. +@param {object} dataset json object.

my.save = function (changes, dataset) {
+  var dfd       = $.Deferred();
+  var total     = changes.creates.length + changes.updates.length + changes.deletes.length;  
+  var results   = {'done': [], 'fail': [] };
+
+  var decr_cb = function () { total -= 1; }
+  var resolve_cb = function () { if (total == 0) dfd.resolve(results); }
+
+  for (var i in changes.creates) {
+    var new_doc = changes.creates[i];
+    var succ_cb = function (msg) {results.done.push({'op': 'create', 'record': new_doc, 'reason': ''}); }
+    var fail_cb = function (msg) {results.fail.push({'op': 'create', 'record': new_doc, 'reason': msg}); }
+
+    _createDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]);
+  }
+
+  for (var i in changes.updates) {
+    var new_doc = changes.updates[i];
+    var succ_cb = function (msg) {results.done.push({'op': 'update', 'record': new_doc, 'reason': ''}); }
+    var fail_cb = function (msg) {results.fail.push({'op': 'update', 'record': new_doc, 'reason': msg}); }
+
+    _updateDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]);
+  }
+
+  for (var i in changes.deletes) {
+    var old_doc = changes.deletes[i];
+    var succ_cb = function (msg) {results.done.push({'op': 'delete', 'record': old_doc, 'reason': ''}); }
+    var fail_cb = function (msg) {results.fail.push({'op': 'delete', 'record': old_doc, 'reason': msg}); }
+
+    _deleteDocument(new_doc, dataset).then([decr_cb, succ_cb, resolve_cb], [decr_cb, fail_cb, resolve_cb]);
+  }
+
+  return dfd.promise();
+};

query

+ +

fetch the data from the couchdb view and filter it. +@param {Object} recline.Dataset instance +@param {Object} recline.Query instance.

my.query = function(queryObj, dataset) {
+  var dfd           = $.Deferred();
+  var db_url        = dataset.db_url;
+  var view_url      = dataset.view_url;
+  var query_options = dataset.query_options;
+
+  var cdb = new my.CouchDBWrapper(db_url, view_url); 
+  var cdb_q = cdb._normalizeQuery(queryObj, query_options);
+
+  cdb.query(queryObj, query_options).done(function(records){
+
+    var query_result = { hits: [], total: 0 };
+    _.each(records.rows, function(record) {
+      var doc = {};
+      if (record.hasOwnProperty('doc')) {
+        doc = record['doc'];

couchdb uses _id to identify documents, Backbone models use id. +we add this fix so backbone.Model works correctly.

        doc['id'] = doc['_id'];
+      }
+      else {
+        doc = record['value'];

using dunder to create compound id. need something more robust. +couchdb uses _id to identify documents, Backbone models use id. +we add this fix so backbone.Model works correctly.

        doc['_id'] = doc['id'] = record['id'] + '__' + record['key']; 
+      }
+      query_result.total += 1;
+      query_result.hits.push(doc);
+    });

the following block is borrowed verbatim from recline.backend.Memory +search (with filtering, faceting, and sorting) should be factored +out into a separate library.

    query_result.hits = _applyFilters(query_result.hits, queryObj);
+    query_result.hits = _applyFreeTextQuery(query_result.hits, queryObj);

not complete sorting!

    _.each(queryObj.sort, function(sortObj) {
+      var fieldName = _.keys(sortObj)[0];
+      query_result.hits = _.sortBy(query_result.hits, function(doc) {
+        var _out = doc[fieldName];
+        return (sortObj[fieldName].order == 'asc') ? _out : -1*_out;
+      });
+    });
+    query_result.total  = query_result.hits.length;
+    query_result.facets = _computeFacets(query_result.hits, queryObj);
+    query_result.hits = query_result.hits.slice(cdb_q.skip, cdb_q.skip + cdb_q.limit+1);
+    dfd.resolve(query_result);
+
+  });
+  
+  return dfd.promise();
+};

in place filtering

_applyFilters = function(results, queryObj) {
+  _.each(queryObj.filters, function(filter) {
+    results = _.filter(results, function(doc) {
+      var fieldId = _.keys(filter.term)[0];
+      return (doc[fieldId] == filter.term[fieldId]);
+    });
+  });
+  return results;
+};

we OR across fields but AND across terms in query string

_applyFreeTextQuery = function(results, queryObj) {
+  if (queryObj.q) {
+    var terms = queryObj.q.split(' ');
+    results = _.filter(results, function(rawdoc) {
+      var matches = true;
+      _.each(terms, function(term) {
+        var foundmatch = false;      
+        _.each(_.keys(rawdoc), function(field) {
+          var value = rawdoc[field];
+          if (value !== null) { value = value.toString(); }

TODO regexes?

          foundmatch = foundmatch || (value === term);

TODO: early out (once we are true should break to spare unnecessary testing) +if (foundmatch) return true;

        });
+        matches = matches && foundmatch;

TODO: early out (once false should break to spare unnecessary testing) +if (!matches) return false;

      });
+      return matches;
+    });
+  }
+  return results;
+};
+
+_computeFacets = function(records, queryObj) {
+  var facetResults = {};
+  if (!queryObj.facets) {
+    return facetResults;
+  }
+  _.each(queryObj.facets, function(query, facetId) {

TODO: remove dependency on recline.Model

    facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
+    facetResults[facetId].termsall = {};
+  });

faceting

  _.each(records, function(doc) {
+    _.each(queryObj.facets, function(query, facetId) {
+      var fieldId = query.terms.field;
+      var val = doc[fieldId];
+      var tmp = facetResults[facetId];
+      if (val) {
+        tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1;
+      } else {
+        tmp.missing = tmp.missing + 1;
+      }
+    });
+  });
+  _.each(queryObj.facets, function(query, facetId) {
+    var tmp = facetResults[facetId];
+    var terms = _.map(tmp.termsall, function(count, term) {
+      return { term: term, count: count };
+    });
+    tmp.terms = _.sortBy(terms, function(item) {

want descending order

      return -item.count;
+    });
+    tmp.terms = tmp.terms.slice(0, 10);
+  });
+  return facetResults;
+};
+     
+_createDocument  = function (new_doc, dataset) {
+  var dfd      = $.Deferred();
+  var db_url   = dataset.db_url;
+  var view_url = dataset.view_url;
+  var _id      = new_doc['id'];
+  var cdb      = new my.CouchDBWrapper(db_url, view_url);
+
+  delete new_doc['id']; 
+
+  if (view_url.search('_all_docs') !== -1) {
+    jqxhr = cdb.get(_id);
+  }
+  else {
+    _id = new_doc['_id'].split('__')[0];
+    jqxhr = cdb.get(_id);
+  }
+
+  jqxhr.done(function(old_doc){
+    if (dataset.record_create)
+      new_doc = dataset.record_create(new_doc, old_doc);
+    new_doc = _.extend(old_doc, new_doc);
+    new_doc['_id'] = _id;
+    dfd.resolve(cdb.upsert(new_doc));            
+  }).fail(function(args){
+    dfd.reject(args);
+  });
+
+  return dfd.promise();
+};
+
+_updateDocument = function (new_doc, dataset) {
+  var dfd      = $.Deferred();
+  var db_url   = dataset.db_url;
+  var view_url = dataset.view_url;
+  var _id      = new_doc['id'];
+  var cdb      = new my.CouchDBWrapper(db_url, view_url);
+
+  delete new_doc['id']; 
+
+  if (view_url.search('_all_docs') !== -1) {
+    jqxhr = cdb.get(_id);
+  }
+  else {
+    _id = new_doc['_id'].split('__')[0];
+    jqxhr = cdb.get(_id);
+  }
+
+  jqxhr.done(function(old_doc){
+    if (dataset.record_update)
+      new_doc = dataset.record_update(new_doc, old_doc);
+    new_doc = _.extend(old_doc, new_doc);
+    new_doc['_id'] = _id;
+    dfd.resolve(cdb.upsert(new_doc));            
+  }).fail(function(args){
+    dfd.reject(args);
+  });
+
+  return dfd.promise();
+};
+
+_deleteDocument = function (del_doc, dataset) {
+  var dfd      = $.Deferred();
+  var db_url   = dataset.db_url;
+  var view_url = dataset.view_url;
+  var _id      = del_doc['id'];
+  var cdb      = new my.CouchDBWrapper(db_url, view_url);
+
+  if (view_url.search('_all_docs') !== -1) 
+    return cdb.delete(_id);
+  else {
+    _id = model.get('_id').split('__')[0];
+    var jqxhr = cdb.get(_id);
+
+    jqxhr.done(function(old_doc){
+      if (dataset.record_delete)
+        old_doc = dataset.record_delete(del_doc, old_doc);
+      if (_.isNull(del_doc))
+        dfd.resolve(cdb.delete(_id)); // XXX is this the right thing to do?
+      else {

couchdb uses _id to identify documents, Backbone models use id. +we should remove it before sending it to the server.

        old_doc['_id'] = _id;
+        delete old_doc['id'];
+        dfd.resolve(cdb.upsert(old_doc)); 
+      }
+    }).fail(function(args){
+      dfd.reject(args);
+    });
+    return dfd.promise();
+};
+
+}(jQuery, this.recline.Backend.CouchDB));
+
+
\ No newline at end of file diff --git a/docs/backend/csv.html b/docs/src/backend.csv.html similarity index 55% rename from docs/backend/csv.html rename to docs/src/backend.csv.html index 54474a6e..88c2bd02 100644 --- a/docs/backend/csv.html +++ b/docs/src/backend.csv.html @@ -1,44 +1,53 @@ - csv.js

csv.js

this.recline = this.recline || {};
+      backend.csv.js           
processField; processField = function (field) { - if (fieldQuoted !== true) { }; for (i = 0; i < s.length; i += 1) { - cur = s.charAt(i); }; var rxIsInt = /^\d+$/, - rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,

backend.csv.js

this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 this.recline.Backend.CSV = this.recline.Backend.CSV || {};
 
-(function(my) {

load

+(function(my) {

fetch

-

Load data from a CSV file referenced in an HTMl5 file object returning the -dataset in the callback

+

3 options

-

@param options as for parseCSV below

  my.load = function(file, callback, options) {
-    var encoding = options.encoding || 'UTF-8';
-    
-    var metadata = {
-      id: file.name,
-      file: file
-    };
-    var reader = new FileReader();

TODO

    reader.onload = function(e) {
-      var dataset = my.csvToDataset(e.target.result, options);
-      callback(dataset);
-    };
-    reader.onerror = function (e) {
-      alert('Failed to load file. Code: ' + e.target.error.code);
-    };
-    reader.readAsText(file, encoding);
-  };
+
    +
  1. CSV local fileobject -> HTML5 file object + CSV parser
  2. +
  3. Already have CSV string (in data) attribute -> CSV parser
  4. +
  5. online CSV file that is ajax-able -> ajax + csv parser
  6. +
- my.csvToDataset = function(csvString, options) { - var out = my.parseCSV(csvString, options); - fields = _.map(out[0], function(cell) { - return { id: cell, label: cell }; - }); - var data = _.map(out.slice(1), function(row) { - var _doc = {}; - _.each(out[0], function(fieldId, idx) { - _doc[fieldId] = row[idx]; +

All options generates similar data and give a memory store outcome

  my.fetch = function(dataset) {
+    var dfd = $.Deferred();
+    if (dataset.file) {
+      var reader = new FileReader();
+      var encoding = dataset.encoding || 'UTF-8';
+      reader.onload = function(e) {
+        var rows = my.parseCSV(e.target.result, dataset);
+        dfd.resolve({
+          records: rows,
+          metadata: {
+            filename: dataset.file.name
+          },
+          useMemoryStore: true
+        });
+      };
+      reader.onerror = function (e) {
+        alert('Failed to load file. Code: ' + e.target.error.code);
+      };
+      reader.readAsText(dataset.file, encoding);
+    } else if (dataset.data) {
+      var rows = my.parseCSV(dataset.data, dataset);
+      dfd.resolve({
+        records: rows,
+        useMemoryStore: true
       });
-      return _doc;
-    });
-    var dataset = recline.Backend.Memory.createDataset(data, fields);
-    return dataset;
-  };

Converts a Comma Separated Values string into an array of arrays. + } else if (dataset.url) { + $.get(dataset.url).done(function(data) { + var rows = my.parseCSV(dataset.data, dataset); + dfd.resolve({ + records: rows, + useMemoryStore: true + }); + }); + } + return dfd.promise(); + };

Converts a Comma Separated Values string into an array of arrays. Each line in the CSV becomes an array.

Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.

@@ -51,14 +60,13 @@ Each line in the CSV becomes an array.

@param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported @param {String} [separator=','] Separator for CSV file Heavily based on uselesscode's JS CSV parser (MIT Licensed): -thttp://www.uselesscode.org/javascript/csv/

  my.parseCSV= function(s, options) {

Get rid of any trailing \n

    s = chomp(s);
+http://www.uselesscode.org/javascript/csv/

  my.parseCSV= function(s, options) {

Get rid of any trailing \n

    s = chomp(s);
 
     var options = options || {};
-    var trm = options.trim;
+    var trm = (options.trim === false) ? false : true;
     var separator = options.separator || ',';
     var delimiter = options.delimiter || '"';
 
-
     var cur = '', // The character we are currently processing.
       inQuote = false,
       fieldQuoted = false,
@@ -69,10 +77,10 @@ thttp://www.uselesscode.org/javascript/csv/

If field is empty set to null

        if (field === '') {
-          field = null;

If the field was not quoted and we are trimming fields, trim it

        } else if (trm === true) {
+      if (fieldQuoted !== true) {

If field is empty set to null

        if (field === '') {
+          field = null;

If the field was not quoted and we are trimming fields, trim it

        } else if (trm === true) {
           field = trim(field);
-        }

Convert unquoted numbers to their appropriate types

        if (rxIsInt.test(field)) {
+        }

Convert unquoted numbers to their appropriate types

        if (rxIsInt.test(field)) {
           field = parseInt(field, 10);
         } else if (rxIsFloat.test(field)) {
           field = parseFloat(field, 10);
@@ -82,25 +90,25 @@ thttp://www.uselesscode.org/javascript/csv/

If we are at a EOF or EOR

      if (inQuote === false && (cur === separator || cur === "\n")) {
-	field = processField(field);

Add the current field to the current row

        row.push(field);

If this is EOR append row to output and flush row

        if (cur === "\n") {
+      cur = s.charAt(i);

If we are at a EOF or EOR

      if (inQuote === false && (cur === separator || cur === "\n")) {
+	field = processField(field);

Add the current field to the current row

        row.push(field);

If this is EOR append row to output and flush row

        if (cur === "\n") {
           out.push(row);
           row = [];
-        }

Flush the field buffer

        field = '';
+        }

Flush the field buffer

        field = '';
         fieldQuoted = false;
-      } else {

If it's not a delimiter, add it to the field buffer

        if (cur !== delimiter) {
+      } else {

If it's not a delimiter, add it to the field buffer

        if (cur !== delimiter) {
           field += cur;
         } else {
-          if (!inQuote) {

We are not in a quote, start a quote

            inQuote = true;
+          if (!inQuote) {

We are not in a quote, start a quote

            inQuote = true;
             fieldQuoted = true;
-          } else {

Next char is delimiter, this is an escaped delimiter

            if (s.charAt(i + 1) === delimiter) {
-              field += delimiter;

Skip the next char

              i += 1;
-            } else {

It's not escaping, so end quote

              inQuote = false;
+          } else {

Next char is delimiter, this is an escaped delimiter

            if (s.charAt(i + 1) === delimiter) {
+              field += delimiter;

Skip the next char

              i += 1;
+            } else {

It's not escaping, so end quote

              inQuote = false;
             }
           }
         }
       }
-    }

Add the last field

    field = processField(field);
+    }

Add the last field

    field = processField(field);
     row.push(field);
     out.push(row);
 
@@ -108,10 +116,10 @@ thttp://www.uselesscode.org/javascript/csv/

If a string has leading or trailing space, + rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,

If a string has leading or trailing space, contains a comma double quote or a newline it needs to be quoted in CSV output

    rxNeedsQuoting = /^\s|\s$|,|"|\n/,
-    trim = (function () {

Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists

      if (String.prototype.trim) {
+    trim = (function () {

Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists

      if (String.prototype.trim) {
         return function (s) {
           return s.trim();
         };
@@ -123,8 +131,8 @@ it needs to be quoted in CSV output

}()); function chomp(s) { - if (s.charAt(s.length - 1) !== "\n") {

Does not end with \n, just return string

      return s;
-    } else {

Remove the \n

      return s.substring(0, s.length - 1);
+    if (s.charAt(s.length - 1) !== "\n") {

Does not end with \n, just return string

      return s;
+    } else {

Remove the \n

      return s.substring(0, s.length - 1);
     }
   }
 
diff --git a/docs/src/backend.dataproxy.html b/docs/src/backend.dataproxy.html
new file mode 100644
index 00000000..97d3d9bf
--- /dev/null
+++ b/docs/src/backend.dataproxy.html
@@ -0,0 +1,63 @@
+      backend.dataproxy.js           

backend.dataproxy.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
+
+(function($, my) {
+  my.__type__ = 'dataproxy';

URL for the dataproxy

  my.dataproxy_url = 'http://jsonpdataproxy.appspot.com';

load

+ +

Load data from a URL via the DataProxy.

+ +

Returns array of field names and array of arrays for records

  my.fetch = function(dataset) {
+    var data = {
+      url: dataset.url,
+      'max-results':  dataset.size || dataset.rows || 1000,
+      type: dataset.format || ''
+    };
+    var jqxhr = $.ajax({
+      url: my.dataproxy_url,
+      data: data,
+      dataType: 'jsonp'
+    });
+    var dfd = $.Deferred();
+    _wrapInTimeout(jqxhr).done(function(results) {
+      if (results.error) {
+        dfd.reject(results.error);
+      }
+
+      dfd.resolve({
+        records: results.data,
+        fields: results.fields,
+        useMemoryStore: true
+      });
+    })
+    .fail(function(arguments) {
+      dfd.reject(arguments);
+    });
+    return dfd.promise();
+  };

_wrapInTimeout

+ +

Convenience method providing a crude way to catch backend errors on JSONP calls. +Many of backends use JSONP and so will not get error messages and this is +a crude way to catch those errors.

  var _wrapInTimeout = function(ourFunction) {
+    var dfd = $.Deferred();
+    var timeout = 5000;
+    var timer = setTimeout(function() {
+      dfd.reject({
+        message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+      });
+    }, timeout);
+    ourFunction.done(function(arguments) {
+        clearTimeout(timer);
+        dfd.resolve(arguments);
+      })
+      .fail(function(arguments) {
+        clearTimeout(timer);
+        dfd.reject(arguments);
+      })
+      ;
+    return dfd.promise();
+  }
+
+}(jQuery, this.recline.Backend.DataProxy));
+
+
\ No newline at end of file diff --git a/docs/src/backend.elasticsearch.html b/docs/src/backend.elasticsearch.html new file mode 100644 index 00000000..df2073d0 --- /dev/null +++ b/docs/src/backend.elasticsearch.html @@ -0,0 +1,225 @@ + backend.elasticsearch.js

backend.elasticsearch.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {};
+
+(function($, my) {
+  my.__type__ = 'elasticsearch';

ElasticSearch Wrapper

+ +

A simple JS wrapper around an ElasticSearch endpoints.

+ +

@param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running +on http://localhost:9200 with index twitter and type tweet it would be:

+ +
http://localhost:9200/twitter/tweet
+ +

@param {Object} options: set of options such as:

+ +
    +
  • headers - {dict of headers to add to each request}
  • +
  • dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests)
  • +
  my.Wrapper = function(endpoint, options) { 
+    var self = this;
+    this.endpoint = endpoint;
+    this.options = _.extend({
+        dataType: 'json'
+      },
+      options);

mapping

+ +

Get ES mapping for this type/table

+ +

@return promise compatible deferred object.

    this.mapping = function() {
+      var schemaUrl = self.endpoint + '/_mapping';
+      var jqxhr = makeRequest({
+        url: schemaUrl,
+        dataType: this.options.dataType
+      });
+      return jqxhr;
+    };

get

+ +

Get record corresponding to specified id

+ +

@return promise compatible deferred object.

    this.get = function(id) {
+      var base = this.endpoint + '/' + id;
+      return makeRequest({
+        url: base,
+        dataType: 'json'
+      });
+    };

upsert

+ +

create / update a record to ElasticSearch backend

+ +

@param {Object} doc an object to insert to the index. +@return deferred supporting promise API

    this.upsert = function(doc) {
+      var data = JSON.stringify(doc);
+      url = this.endpoint;
+      if (doc.id) {
+        url += '/' + doc.id;
+      }
+      return makeRequest({
+        url: url,
+        type: 'POST',
+        data: data,
+        dataType: 'json'
+      });
+    };

delete

+ +

Delete a record from the ElasticSearch backend.

+ +

@param {Object} id id of object to delete +@return deferred supporting promise API

    this.delete = function(id) {
+      url = this.endpoint;
+      url += '/' + id;
+      return makeRequest({
+        url: url,
+        type: 'DELETE',
+        dataType: 'json'
+      });
+    };
+
+    this._normalizeQuery = function(queryObj) {
+      var self = this;
+      var queryInfo = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
+      var out = {
+        constant_score: {
+          query: {}
+        }
+      };
+      if (!queryInfo.q) {
+        out.constant_score.query = {
+          match_all: {}
+        };
+      } else {
+        out.constant_score.query = {
+          query_string: {
+            query: queryInfo.q
+          }
+        };
+      }
+      if (queryInfo.filters && queryInfo.filters.length) {
+        out.constant_score.filter = {
+          and: []
+        };
+        _.each(queryInfo.filters, function(filter) {
+          out.constant_score.filter.and.push(self._convertFilter(filter));
+        });
+      }
+      return out;
+    },
+
+    this._convertFilter = function(filter) {
+      var out = {};
+      out[filter.type] = {}
+      if (filter.type === 'term') {
+        out.term[filter.field] = filter.term.toLowerCase();
+      } else if (filter.type === 'geo_distance') {
+        out.geo_distance[filter.field] = filter.point;
+        out.geo_distance.distance = filter.distance;
+        out.geo_distance.unit = filter.unit;
+      }
+      return out;
+    },

query

+ +

@return deferred supporting promise API

    this.query = function(queryObj) {
+      var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
+      var queryNormalized = this._normalizeQuery(queryObj);
+      delete esQuery.q;
+      delete esQuery.filters;
+      esQuery.query = queryNormalized;
+      var data = {source: JSON.stringify(esQuery)};
+      var url = this.endpoint + '/_search';
+      var jqxhr = makeRequest({
+        url: url,
+        data: data,
+        dataType: this.options.dataType
+      });
+      return jqxhr;
+    }
+  };

Recline Connectors

+ +

Requires URL of ElasticSearch endpoint to be specified on the dataset +via the url attribute.

ES options which are passed through to options on Wrapper (see Wrapper for details)

  my.esOptions = {};

fetch

  my.fetch = function(dataset) {
+    var es = new my.Wrapper(dataset.url, my.esOptions);
+    var dfd = $.Deferred();
+    es.mapping().done(function(schema) {

only one top level key in ES = the type so we can ignore it

      var key = _.keys(schema)[0];
+      var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
+        dict.id = fieldName;
+        return dict;
+      });
+      dfd.resolve({
+        fields: fieldData
+      });
+    })
+    .fail(function(arguments) {
+      dfd.reject(arguments);
+    });
+    return dfd.promise();
+  };

save

  my.save = function(changes, dataset) {
+    var es = new my.Wrapper(dataset.url, my.esOptions);
+    if (changes.creates.length + changes.updates.length + changes.deletes.length > 1) {
+      var dfd = $.Deferred();
+      msg = 'Saving more than one item at a time not yet supported';
+      alert(msg);
+      dfd.reject(msg);
+      return dfd.promise();
+    }
+    if (changes.creates.length > 0) {
+      return es.upsert(changes.creates[0]);
+    }
+    else if (changes.updates.length >0) {
+      return es.upsert(changes.updates[0]);
+    } else if (changes.deletes.length > 0) {
+      return es.delete(changes.deletes[0].id);
+    }
+  };

query

  my.query = function(queryObj, dataset) {
+    var dfd = $.Deferred();
+    var es = new my.Wrapper(dataset.url, my.esOptions);
+    var jqxhr = es.query(queryObj);
+    jqxhr.done(function(results) {
+      var out = {
+        total: results.hits.total,
+      };
+      out.hits = _.map(results.hits.hits, function(hit) {
+        if (!('id' in hit._source) && hit._id) {
+          hit._source.id = hit._id;
+        }
+        return hit._source;
+      });
+      if (results.facets) {
+        out.facets = results.facets;
+      }
+      dfd.resolve(out);
+    }).fail(function(errorObj) {
+      var out = {
+        title: 'Failed: ' + errorObj.status + ' code',
+        message: errorObj.responseText
+      };
+      dfd.reject(out);
+    });
+    return dfd.promise();
+  };

makeRequest

+ +

Just $.ajax but in any headers in the 'headers' attribute of this +Backend instance. Example:

+ +
+var jqxhr = this._makeRequest({
+  url: the-url
+});
+
var makeRequest = function(data, headers) {
+  var extras = {};
+  if (headers) {
+    extras = {
+      beforeSend: function(req) {
+        _.each(headers, function(value, key) {
+          req.setRequestHeader(key, value);
+        });
+      }
+    };
+  }
+  var data = _.extend(extras, data);
+  return $.ajax(data);
+};
+
+}(jQuery, this.recline.Backend.ElasticSearch));
+
+
\ No newline at end of file diff --git a/docs/src/backend.gdocs.html b/docs/src/backend.gdocs.html new file mode 100644 index 00000000..c9ce9ed4 --- /dev/null +++ b/docs/src/backend.gdocs.html @@ -0,0 +1,108 @@ + backend.gdocs.js

backend.gdocs.js

this.recline = this.recline || {};
+this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.GDocs = this.recline.Backend.GDocs || {};
+
+(function($, my) {
+  my.__type__ = 'gdocs';

Google spreadsheet backend

+ +

Fetch data from a Google Docs spreadsheet.

+ +

Dataset must have a url attribute pointing to the Gdocs or its JSON feed e.g.

+ +
+var dataset = new recline.Model.Dataset({
+    url: 'https://docs.google.com/spreadsheet/ccc?key=0Aon3JiuouxLUdGlQVDJnbjZRSU1tUUJWOUZXRG53VkE#gid=0'
+  },
+  'gdocs'
+);
+
+var dataset = new recline.Model.Dataset({
+    url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
+  },
+  'gdocs'
+);
+
+ +

@return object with two attributes

+ +
    +
  • fields: array of Field objects
  • +
  • records: array of objects for each row
  • +
  my.fetch = function(dataset) {
+    var dfd = $.Deferred(); 
+    var url = my.getSpreadsheetAPIUrl(dataset.url);
+    $.getJSON(url, function(d) {
+      result = my.parseData(d);
+      var fields = _.map(result.fields, function(fieldId) {
+        return {id: fieldId};
+      });
+      dfd.resolve({
+        records: result.records,
+        fields: fields,
+        useMemoryStore: true
+      });
+    });
+    return dfd.promise();
+  };

parseData

+ +

Parse data from Google Docs API into a reasonable form

+ +

:options: (optional) optional argument dictionary: +columnsToUse: list of columns to use (specified by field names) +colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). +:return: tabular data object (hash with keys: field and data).

+ +

Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.

  my.parseData = function(gdocsSpreadsheet) {
+    var options = {};
+    if (arguments.length > 1) {
+      options = arguments[1];
+    }
+    var results = {
+      fields: [],
+      records: []
+    };

default is no special info on type of columns

    var colTypes = {};
+    if (options.colTypes) {
+      colTypes = options.colTypes;
+    }
+    if (gdocsSpreadsheet.feed.entry.length > 0) {
+      for (var k in gdocsSpreadsheet.feed.entry[0]) {
+        if (k.substr(0, 3) == 'gsx') {
+          var col = k.substr(4);
+          results.fields.push(col);
+        }
+      }
+    }

converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])

    var rep = /^([\d\.\-]+)\%$/;
+    results.records = _.map(gdocsSpreadsheet.feed.entry, function(entry) {
+      var row = {};
+      _.each(results.fields, function(col) {
+        var _keyname = 'gsx$' + col;
+        var value = entry[_keyname]['$t'];

if labelled as % and value contains %, convert

        if (colTypes[col] == 'percent') {
+          if (rep.test(value)) {
+            var value2 = rep.exec(value);
+            var value3 = parseFloat(value2);
+            value = value3 / 100;
+          }
+        }
+        row[col] = value;
+      });
+      return row;
+    });
+    return results;
+  };

Convenience function to get GDocs JSON API Url from standard URL

  my.getSpreadsheetAPIUrl = function(url) {
+    if (url.indexOf('feeds/list') != -1) {
+      return url;
+    } else {

https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0

      var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
+      var matches = url.match(regex);
+      if (matches) {
+        var key = matches[1];
+        var worksheet = 1;
+        var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json';
+        return out;
+      } else {
+        alert('Failed to extract gdocs key from ' + url);
+      }
+    }
+  };
+}(jQuery, this.recline.Backend.GDocs));
+
+
\ No newline at end of file diff --git a/docs/backend/memory.html b/docs/src/backend.memory.html similarity index 59% rename from docs/backend/memory.html rename to docs/src/backend.memory.html index a1a95921..f2790485 100644 --- a/docs/backend/memory.html +++ b/docs/src/backend.memory.html @@ -1,30 +1,19 @@ - memory.js

memory.js

this.recline = this.recline || {};
+      backend.memory.js           

backend.memory.js

this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 this.recline.Backend.Memory = this.recline.Backend.Memory || {};
 
-(function($, my) {

createDataset

- -

Convenience function to create a simple 'in-memory' dataset in one step.

- -

@param data: list of hashes for each document/row in the data ({key: -value, key: value}) -@param fields: (optional) list of field hashes (each hash defining a hash -as per recline.Model.Field). If fields not specified they will be taken -from the data. -@param metadata: (optional) dataset metadata - see recline.Model.Dataset. -If not defined (or id not provided) id will be autogenerated.

  my.createDataset = function(data, fields, metadata) {
-    var wrapper = new my.DataWrapper(data, fields);
-    var backend = new my.Backbone();
-    var dataset = new recline.Model.Dataset(metadata, backend);
-    dataset._dataCache = wrapper;
-    dataset.fetch();
-    dataset.query();
-    return dataset;
-  };

Data Wrapper

+(function($, my) { + my.__type__ = 'memory';

Data Wrapper

Turn a simple array of JS objects into a mini data-store with functionality like querying, faceting, updating (by ID) and deleting (by -ID).

  my.DataWrapper = function(data, fields) {
+ID).

+ +

@param data list of hashes for each record/row in the data ({key: +value, key: value}) +@param fields (optional) list of field hashes (each hash defining a field +as per recline.Model.Field). If fields not specified they will be taken +from the data.

  my.Store = function(data, fields) {
     var self = this;
     this.data = data;
     if (fields) {
@@ -52,7 +41,20 @@ ID).

this.data = newdocs; }; + this.save = function(changes, dataset) { + var self = this; + var dfd = $.Deferred();

TODO _.each(changes.creates) { ... }

      _.each(changes.updates, function(record) {
+        self.update(record);
+      });
+      _.each(changes.deletes, function(record) {
+        self.delete(record);
+      });
+      dfd.resolve();
+      return dfd.promise();
+    },
+
     this.query = function(queryObj) {
+      var dfd = $.Deferred();
       var numRows = queryObj.size || this.data.length;
       var start = queryObj.from || 0;
       var results = this.data;
@@ -61,26 +63,29 @@ ID).

var fieldName = _.keys(sortObj)[0]; results = _.sortBy(results, function(doc) { var _out = doc[fieldName]; - return (sortObj[fieldName].order == 'asc') ? _out : -1*_out; + return _out; }); + if (sortObj[fieldName].order == 'desc') { + results.reverse(); + } }); - var total = results.length; var facets = this.computeFacets(results, queryObj); - results = results.slice(start, start+numRows); - return { - total: total, - documents: results, + var out = { + total: results.length, + hits: results.slice(start, start+numRows), facets: facets }; + dfd.resolve(out); + return dfd.promise(); };

in place filtering

    this._applyFilters = function(results, queryObj) {
-      _.each(queryObj.filters, function(filter) {
-        results = _.filter(results, function(doc) {
-          var fieldId = _.keys(filter.term)[0];
-          return (doc[fieldId] == filter.term[fieldId]);
-        });
+      _.each(queryObj.filters, function(filter) {

if a term filter ...

        if (filter.type === 'term') {
+          results = _.filter(results, function(doc) {
+            return (doc[filter.field] == filter.term);
+          });
+        }
       });
       return results;
-    };

we OR across fields but AND across terms in query string

    this._applyFreeTextQuery = function(results, queryObj) {
+    };

we OR across fields but AND across terms in query string

    this._applyFreeTextQuery = function(results, queryObj) {
       if (queryObj.q) {
         var terms = queryObj.q.split(' ');
         results = _.filter(results, function(rawdoc) {
@@ -89,9 +94,9 @@ ID).

var foundmatch = false; _.each(self.fields, function(field) { var value = rawdoc[field.id]; - if (value !== null) { value = value.toString(); }

TODO regexes?

              foundmatch = foundmatch || (value === term);

TODO: early out (once we are true should break to spare unnecessary testing) + if (value !== null) { value = value.toString(); }

TODO regexes?

              foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase());

TODO: early out (once we are true should break to spare unnecessary testing) if (foundmatch) return true;

            });
-            matches = matches && foundmatch;

TODO: early out (once false should break to spare unnecessary testing) + matches = matches && foundmatch;

TODO: early out (once false should break to spare unnecessary testing) if (!matches) return false;

          });
           return matches;
         });
@@ -99,14 +104,14 @@ if (!matches) return false;

return results; }; - this.computeFacets = function(documents, queryObj) { + this.computeFacets = function(records, queryObj) { var facetResults = {}; if (!queryObj.facets) { return facetResults; } - _.each(queryObj.facets, function(query, facetId) {

TODO: remove dependency on recline.Model

        facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
+      _.each(queryObj.facets, function(query, facetId) {

TODO: remove dependency on recline.Model

        facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
         facetResults[facetId].termsall = {};
-      });

faceting

      _.each(documents, function(doc) {
+      });

faceting

      _.each(records, function(doc) {
         _.each(queryObj.facets, function(query, facetId) {
           var fieldId = query.terms.field;
           var val = doc[fieldId];
@@ -123,58 +128,13 @@ if (!matches) return false;

var terms = _.map(tmp.termsall, function(count, term) { return { term: term, count: count }; }); - tmp.terms = _.sortBy(terms, function(item) {

want descending order

          return -item.count;
+        tmp.terms = _.sortBy(terms, function(item) {

want descending order

          return -item.count;
         });
         tmp.terms = tmp.terms.slice(0, 10);
       });
       return facetResults;
     };
   };
-  

Backbone

- -

Backbone connector for memory store attached to a Dataset object

  my.Backbone = function() {
-    this.__type__ = 'memory';
-    this.sync = function(method, model, options) {
-      var self = this;
-      var dfd = $.Deferred();
-      if (method === "read") {
-        if (model.__type__ == 'Dataset') {
-          model.fields.reset(model._dataCache.fields);
-          dfd.resolve(model);
-        }
-        return dfd.promise();
-      } else if (method === 'update') {
-        if (model.__type__ == 'Document') {
-          model.dataset._dataCache.update(model.toJSON());
-          dfd.resolve(model);
-        }
-        return dfd.promise();
-      } else if (method === 'delete') {
-        if (model.__type__ == 'Document') {
-          model.dataset._dataCache.delete(model.toJSON());
-          dfd.resolve(model);
-        }
-        return dfd.promise();
-      } else {
-        alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model);
-      }
-    };
-
-    this.query = function(model, queryObj) {
-      var dfd = $.Deferred();
-      var results = model._dataCache.query(queryObj);
-      var hits = _.map(results.documents, function(row) {
-        return { _source: row };
-      });
-      var out = {
-        total: results.total,
-        hits: hits,
-        facets: results.facets
-      };
-      dfd.resolve(out);
-      return dfd.promise();
-    };
-  };
 
 }(jQuery, this.recline.Backend.Memory));
 
diff --git a/docs/src/model.html b/docs/src/model.html
index 2fd24970..b7ae3e0c 100644
--- a/docs/src/model.html
+++ b/docs/src/model.html
@@ -1,97 +1,170 @@
-      model.js           

model.js

Recline Backbone Models

this.recline = this.recline || {};
+      model.js           

model.js

Recline Backbone Models

this.recline = this.recline || {};
 this.recline.Model = this.recline.Model || {};
 
-(function($, my) {

A Dataset model

- -

A model has the following (non-Backbone) attributes:

- -

@property {FieldList} fields: (aka columns) is a FieldList listing all the -fields on this Dataset (this can be set explicitly, or, will be set by -Dataset.fetch() or Dataset.query()

- -

@property {DocumentList} currentDocuments: a DocumentList containing the -Documents we have currently loaded for viewing (updated by calling query -method)

- -

@property {number} docCount: total number of documents in this dataset

- -

@property {Backend} backend: the Backend (instance) for this Dataset.

- -

@property {Query} queryState: Query object which stores current -queryState. queryState may be edited by other components (e.g. a query -editor view) changes will trigger a Dataset query.

- -

@property {FacetList} facets: FacetList object containing all current -Facets.

my.Dataset = Backbone.Model.extend({
-  __type__: 'Dataset',

initialize

- -

Sets up instance properties (see above)

- -

@param {Object} model: standard set of model attributes passed to Backbone models

- -

@param {Object or String} backend: Backend instance (see -recline.Backend.Base) or a string specifying that instance. The -string specifying may be a full class path e.g. -'recline.Backend.ElasticSearch' or a simple name e.g. -'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in -recline.Backend module)

  initialize: function(model, backend) {
+(function($, my) {

Dataset

my.Dataset = Backbone.Model.extend({
+  __type__: 'Dataset',

initialize

  initialize: function() {
     _.bindAll(this, 'query');
-    this.backend = backend;
-    if (typeof(backend) === 'string') {
-      this.backend = this._backendFromString(backend);
+    this.backend = null;
+    if (this.get('backend')) {
+      this.backend = this._backendFromString(this.get('backend'));
+    } else { // try to guess backend ...
+      if (this.get('records')) {
+        this.backend = recline.Backend.Memory;
+      }
     }
     this.fields = new my.FieldList();
-    this.currentDocuments = new my.DocumentList();
+    this.currentRecords = new my.RecordList();
+    this._changes = {
+      deletes: [],
+      updates: [],
+      creates: []
+    };
     this.facets = new my.FacetList();
     this.docCount = null;
     this.queryState = new my.Query();
     this.queryState.bind('change', this.query);
-    this.queryState.bind('facet:add', this.query);
-  },

query

+ this.queryState.bind('facet:add', this.query);

store is what we query and save against +store will either be the backend or be a memory store if Backend fetch +tells us to use memory store

    this._store = this.backend;
+    if (this.backend == recline.Backend.Memory) {
+      this.fetch();
+    }
+  },

fetch

-

AJAX method with promise API to get documents from the backend.

+

Retrieve dataset and (some) records from the backend.

  fetch: function() {
+    var self = this;
+    var dfd = $.Deferred();
+
+    if (this.backend !== recline.Backend.Memory) {
+      this.backend.fetch(this.toJSON())
+        .done(handleResults)
+        .fail(function(arguments) {
+          dfd.reject(arguments);
+        });
+    } else {

special case where we have been given data directly

      handleResults({
+        records: this.get('records'),
+        fields: this.get('fields'),
+        useMemoryStore: true
+      });
+    }
+
+    function handleResults(results) {
+      var out = self._normalizeRecordsAndFields(results.records, results.fields);
+      if (results.useMemoryStore) {
+        self._store = new recline.Backend.Memory.Store(out.records, out.fields);
+      }
+
+      self.set(results.metadata);
+      self.fields.reset(out.fields);
+      self.query()
+        .done(function() {
+          dfd.resolve(self);
+        })
+        .fail(function(arguments) {
+          dfd.reject(arguments);
+        });
+    }
+
+    return dfd.promise();
+  },

_normalizeRecordsAndFields

+ +

Get a proper set of fields and records from incoming set of fields and records either of which may be null or arrays or objects

+ +

e.g. fields = ['a', 'b', 'c'] and records = [ [1,2,3] ] => +fields = [ {id: a}, {id: b}, {id: c}], records = [ {a: 1}, {b: 2}, {c: 3}]

  _normalizeRecordsAndFields: function(records, fields) {

if no fields get them from records

    if (!fields && records && records.length > 0) {

records is array then fields is first row of records ...

      if (records[0] instanceof Array) {
+        fields = records[0];
+        records = records.slice(1);
+      } else {
+        fields = _.map(_.keys(records[0]), function(key) {
+          return {id: key};
+        });
+      }
+    } 

fields is an array of strings (i.e. list of field headings/ids)

    if (fields && fields.length > 0 && typeof fields[0] === 'string') {

Rename duplicate fieldIds as each field name needs to be +unique.

      var seen = {};
+      fields = _.map(fields, function(field, index) {

cannot use trim as not supported by IE7

        var fieldId = field.replace(/^\s+|\s+$/g, '');
+        if (fieldId === '') {
+          fieldId = '_noname_';
+          field = fieldId;
+        }
+        while (fieldId in seen) {
+          seen[field] += 1;
+          fieldId = field + seen[field];
+        }
+        if (!(field in seen)) {
+          seen[field] = 0;
+        }

TODO: decide whether to keep original name as label ... +return { id: fieldId, label: field || fieldId }

        return { id: fieldId };
+      });
+    }

records is provided as arrays so need to zip together with fields +NB: this requires you to have fields to match arrays

    if (records && records.length > 0 && records[0] instanceof Array) {
+      records = _.map(records, function(doc) {
+        var tmp = {};
+        _.each(fields, function(field, idx) {
+          tmp[field.id] = doc[idx];
+        });
+        return tmp;
+      });
+    }
+    return {
+      fields: fields,
+      records: records
+    };
+  },
+
+  save: function() {
+    var self = this;

TODO: need to reset the changes ...

    return this._store.save(this._changes, this.toJSON());
+  },

query

+ +

AJAX method with promise API to get records from the backend.

It will query based on current query state (given by this.queryState) updated by queryObj (if provided).

-

Resulting DocumentList are used to reset this.currentDocuments and are +

Resulting RecordList are used to reset this.currentRecords and are also returned.

  query: function(queryObj) {
     var self = this;
-    this.trigger('query:start');
-    var actualQuery = self._prepareQuery(queryObj);
     var dfd = $.Deferred();
-    this.backend.query(this, actualQuery).done(function(queryResult) {
-      self.docCount = queryResult.total;
-      var docs = _.map(queryResult.hits, function(hit) {
-        var _doc = new my.Document(hit._source);
-        _doc.backend = self.backend;
-        _doc.dataset = self;
-        return _doc;
+    this.trigger('query:start');
+
+    if (queryObj) {
+      this.queryState.set(queryObj);
+    }
+    var actualQuery = this.queryState.toJSON();
+
+    this._store.query(actualQuery, this.toJSON())
+      .done(function(queryResult) {
+        self._handleQueryResult(queryResult);
+        self.trigger('query:done');
+        dfd.resolve(self.currentRecords);
+      })
+      .fail(function(arguments) {
+        self.trigger('query:fail', arguments);
+        dfd.reject(arguments);
       });
-      self.currentDocuments.reset(docs);
-      if (queryResult.facets) {
-        var facets = _.map(queryResult.facets, function(facetResult, facetId) {
-          facetResult.id = facetId;
-          return new my.Facet(facetResult);
-        });
-        self.facets.reset(facets);
-      }
-      self.trigger('query:done');
-      dfd.resolve(self.currentDocuments);
-    })
-    .fail(function(arguments) {
-      self.trigger('query:fail', arguments);
-      dfd.reject(arguments);
-    });
     return dfd.promise();
   },
 
-  _prepareQuery: function(newQueryObj) {
-    if (newQueryObj) {
-      this.queryState.set(newQueryObj);
+  _handleQueryResult: function(queryResult) {
+    var self = this;
+    self.docCount = queryResult.total;
+    var docs = _.map(queryResult.hits, function(hit) {
+      var _doc = new my.Record(hit);
+      _doc.bind('change', function(doc) {
+        self._changes.updates.push(doc.toJSON());
+      });
+      _doc.bind('destroy', function(doc) {
+        self._changes.deletes.push(doc.toJSON());
+      });
+      return _doc;
+    });
+    self.currentRecords.reset(docs);
+    if (queryResult.facets) {
+      var facets = _.map(queryResult.facets, function(facetResult, facetId) {
+        facetResult.id = facetId;
+        return new my.Facet(facetResult);
+      });
+      self.facets.reset(facets);
     }
-    var out = this.queryState.toJSON();
-    return out;
   },
 
   toTemplateJSON: function() {
@@ -99,10 +172,30 @@ also returned.

data.docCount = this.docCount; data.fields = this.fields.toJSON(); return data; - },

_backendFromString(backendString)

+ },

Get a summary for each field in the form of a Facet.

+ +

@return null as this is async function. Provides deferred/promise interface.

  getFieldsSummary: function() {
+    var self = this;
+    var query = new my.Query();
+    query.set({size: 0});
+    this.fields.each(function(field) {
+      query.addFacet(field.id);
+    });
+    var dfd = $.Deferred();
+    this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) {
+      if (queryResult.facets) {
+        _.each(queryResult.facets, function(facetResult, facetId) {
+          facetResult.id = facetId;
+          var facet = new my.Facet(facetResult);

TODO: probably want replace rather than reset (i.e. just replace the facet with this id)

          self.fields.get(facetId).facets.reset(facet);
+        });
+      }
+      dfd.resolve(queryResult);
+    });
+    return dfd.promise();
+  },

_backendFromString(backendString)

See backend argument to initialize for details

  _backendFromString: function(backendString) {
-    var parts = backendString.split('.');

walk through the specified path xxx.yyy.zzz to get the final object which should be backend class

    var current = window;
+    var parts = backendString.split('.');

walk through the specified path xxx.yyy.zzz to get the final object which should be backend class

    var current = window;
     for(ii=0;ii<parts.length;ii++) {
       if (!current) {
         break;
@@ -110,18 +203,18 @@ also returned.

current = current[parts[ii]]; } if (current) { - return new current(); - }

alternatively we just had a simple string

    var backend = null;
+      return current;
+    }

alternatively we just had a simple string

    var backend = null;
     if (recline && recline.Backend) {
       _.each(_.keys(recline.Backend), function(name) {
         if (name.toLowerCase() === backendString.toLowerCase()) {
-          backend = new recline.Backend[name].Backbone();
+          backend = recline.Backend[name];
         }
       });
     }
     return backend;
   }
-});

Dataset.restore

+});

Dataset.restore

Restore a Dataset instance from a serialized state. Serialized state for a Dataset is an Object like:

@@ -134,94 +227,71 @@ Dataset is an Object like:

url: {dataset url} ... }

my.Dataset.restore = function(state) {
-  var dataset = null;

hack-y - restoring a memory dataset does not mean much ...

  if (state.backend === 'memory') {
-    dataset = recline.Backend.Memory.createDataset(
-      [{stub: 'this is a stub dataset because we do not restore memory datasets'}],
-      [],
-      state.dataset // metadata
-    );
+  var dataset = null;

hack-y - restoring a memory dataset does not mean much ...

  if (state.backend === 'memory') {
+    var datasetInfo = {
+      records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}]
+    };
   } else {
     var datasetInfo = {
-      url: state.url
+      url: state.url,
+      backend: state.backend
     };
-    dataset = new recline.Model.Dataset(
-      datasetInfo,
-      state.backend
-    );
   }
+  dataset = new recline.Model.Dataset(datasetInfo);
   return dataset;
-};

A Document (aka Row)

+};

A Record (aka Row)

-

A single entry or row in the dataset

my.Document = Backbone.Model.extend({
-  __type__: 'Document',
+

A single entry or row in the dataset

my.Record = Backbone.Model.extend({
+  __type__: 'Record',
   initialize: function() {
     _.bindAll(this, 'getFieldValue');
-  },

getFieldValue

+ },

getFieldValue

For the provided Field get the corresponding rendered computed data value -for this document.

  getFieldValue: function(field) {
+for this record.

  getFieldValue: function(field) {
+    val = this.getFieldValueUnrendered(field);
+    if (field.renderer) {
+      val = field.renderer(val, field, this.toJSON());
+    }
+    return val;
+  },

getFieldValueUnrendered

+ +

For the provided Field get the corresponding computed data value +for this record.

  getFieldValueUnrendered: function(field) {
     var val = this.get(field.id);
     if (field.deriver) {
       val = field.deriver(val, field, this);
     }
-    if (field.renderer) {
-      val = field.renderer(val, field, this);
-    }
     return val;
-  }
-});

A Backbone collection of Documents

my.DocumentList = Backbone.Collection.extend({
-  __type__: 'DocumentList',
-  model: my.Document
-});

A Field (aka Column) on a Dataset

+ }, -

Following (Backbone) attributes as standard:

- -
    -
  • id: a unique identifer for this field- usually this should match the key in the documents hash
  • -
  • label: (optional: defaults to id) the visible label used for this field
  • -
  • type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on http://www.elasticsearch.org/guide/reference/mapping/
  • -
  • format: (optional) used to indicate how the data should be formatted. For example: -
    • type=date, format=yyyy-mm-dd
    • -
    • type=float, format=percentage
    • -
    • type=string, format=markdown (render as markdown if Showdown available)
  • -
  • is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below).
  • -
- -

Following additional instance properties:

- -

@property {Function} renderer: a function to render the data for this field. -Signature: function(value, field, doc) where value is the value of this -cell, field is corresponding field object and document is the document -object. Note that implementing functions can ignore arguments (e.g. -function(value) would be a valid formatter function).

- -

@property {Function} deriver: a function to derive/compute the value of data -in this field as a function of this field's value (if any) and the current -document, its signature and behaviour is the same as for renderer. Use of -this function allows you to define an entirely new value for data in this -field. This provides support for a) 'derived/computed' fields: i.e. fields -whose data are functions of the data in other fields b) transforming the -value of this field prior to rendering.

- -

Default renderers

- -
    -
  • string -
    • no format provided: pass through but convert http:// to hyperlinks
    • -
    • format = plain: do no processing on the source text
    • -
    • format = markdown: process as markdown (if Showdown library available)
  • -
  • float -
    • format = percentage: format as a percentage
  • -
my.Field = Backbone.Model.extend({

defaults - define default values

  defaults: {
+  summary: function(fields) {
+    var html = '';
+    for (key in this.attributes) {
+      if (key != 'id') {
+        html += '<div><strong>' + key + '</strong>: '+ this.attributes[key] + '</div>';
+      }
+    }
+    return html;
+  },

Override Backbone save, fetch and destroy so they do nothing +Instead, Dataset object that created this Record should take care of +handling these changes (discovery will occur via event notifications) +WARNING: these will not persist unless you call save on Dataset

  fetch: function() {},
+  save: function() {},
+  destroy: function() { this.trigger('destroy', this); }
+});

A Backbone collection of Records

my.RecordList = Backbone.Collection.extend({
+  __type__: 'RecordList',
+  model: my.Record
+});

A Field (aka Column) on a Dataset

my.Field = Backbone.Model.extend({

defaults - define default values

  defaults: {
     label: null,
     type: 'string',
     format: null,
     is_derived: false
-  },

initialize

+ },

initialize

@param {Object} data: standard Backbone model attributes

-

@param {Object} options: renderer and/or deriver functions.

  initialize: function(data, options) {

if a hash not passed in the first argument throw error

    if ('0' in data) {
+

@param {Object} options: renderer and/or deriver functions.

  initialize: function(data, options) {

if a hash not passed in the first argument throw error

    if ('0' in data) {
       throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
     }
     if (this.attributes.label === null) {
@@ -234,6 +304,7 @@ value of this field prior to rendering.

if (!this.renderer) { this.renderer = this.defaultRenderers[this.get('type')]; } + this.facets = new my.FacetList(); }, defaultRenderers: { object: function(val, field, doc) { @@ -258,7 +329,7 @@ value of this field prior to rendering.

} } else if (format == 'plain') { return val; - } else {

as this is the default and default type is string may get things + } else {

as this is the default and default type is string may get things here that are not actually strings

        if (val && typeof val === 'string') {
           val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
         }
@@ -270,91 +341,55 @@ here that are not actually strings

my.FieldList = Backbone.Collection.extend({ model: my.Field -});

Query

- -

Query instances encapsulate a query to the backend (see query method on backend). Useful both -for creating queries and for storing and manipulating query state - -e.g. from a query editor).

- -

Query Structure and format

- -

Query structure should follow that of ElasticSearch query -language.

- -

NB: It is up to specific backends how to implement and support this query -structure. Different backends might choose to implement things differently -or not support certain features. Please check your backend for details.

- -

Query object has the following key attributes:

- - - -

Additions:

- -
    -
  • q: either straight text or a hash will map directly onto a query_string -query -in backend

    - -
    • Of course this can be re-interpreted by different backends. E.g. some -may just pass this straight through e.g. for an SQL backend this could be -the full SQL query
  • -
  • filters: dict of ElasticSearch filters. These will be and-ed together for -execution.

  • -
- -

Examples

- -
-{
-   q: 'quick brown fox',
-   filters: [
-     { term: { 'owner': 'jones' } }
-   ]
-}
-
my.Query = Backbone.Model.extend({
+});

Query

my.Query = Backbone.Model.extend({
   defaults: function() {
     return {
       size: 100,
       from: 0,
-      facets: {},

http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html -, filter: {}

      filters: []
+      q: '',
+      facets: {},
+      filters: []
     };
-  },

addTermFilter

- -

Set (update or add) a terms filter to filters

- -

See http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html

  addTermFilter: function(fieldId, value) {
-    var filters = this.get('filters');
-    var filter = { term: {} };
-    filter.term[fieldId] = value;
-    filters.push(filter);
-    this.set({filters: filters});

change does not seem to be triggered automatically

    if (value) {
-      this.trigger('change');
-    } else {

adding a new blank filter and do not want to trigger a new query

      this.trigger('change:filters:new-blank');
+  },
+  _filterTemplates: {
+    term: {
+      type: 'term',
+      field: '',
+      term: ''
+    },
+    geo_distance: {
+      distance: 10,
+      unit: 'km',
+      point: {
+        lon: 0,
+        lat: 0
+      }
     }
-  },

removeFilter

+ },

addFilter

+ +

Add a new filter (appended to the list of filters)

+ +

@param filter an object specifying the filter - see _filterTemplates for examples. If only type is provided will generate a filter by cloning _filterTemplates

  addFilter: function(filter) {

crude deep copy

    var ourfilter = JSON.parse(JSON.stringify(filter));

not full specified so use template and over-write

    if (_.keys(filter).length <= 2) {
+      ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter);
+    }
+    var filters = this.get('filters');
+    filters.push(ourfilter);
+    this.trigger('change:filters:new-blank');
+  },
+  updateFilter: function(index, value) {
+  },

removeFilter

Remove a filter from filters at index filterIndex

  removeFilter: function(filterIndex) {
     var filters = this.get('filters');
     filters.splice(filterIndex, 1);
     this.set({filters: filters});
     this.trigger('change');
-  },

addFacet

+ },

addFacet

Add a Facet to this query

See http://www.elasticsearch.org/guide/reference/api/search/facets/

  addFacet: function(fieldId) {
-    var facets = this.get('facets');

Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

    if (_.contains(_.keys(facets), fieldId)) {
+    var facets = this.get('facets');

Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

    if (_.contains(_.keys(facets), fieldId)) {
       return;
     }
     facets[fieldId] = {
@@ -374,44 +409,7 @@ execution.

this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); } -});

A Facet (Result)

- -

Object to store Facet information, that is summary information (e.g. values -and counts) about a field obtained by some faceting method on the -backend.

- -

Structure of a facet follows that of Facet results in ElasticSearch, see: -http://www.elasticsearch.org/guide/reference/api/search/facets/

- -

Specifically the object structure of a facet looks like (there is one -addition compared to ElasticSearch: the "id" field which corresponds to the -key used to specify this facet in the facet query):

- -
-{
-  "id": "id-of-facet",
-  // type of this facet (terms, range, histogram etc)
-  "_type" : "terms",
-  // total number of tokens in the facet
-  "total": 5,
-  // @property {number} number of documents which have no value for the field
-  "missing" : 0,
-  // number of facet values not included in the returned facets
-  "other": 0,
-  // term object ({term: , count: ...})
-  "terms" : [ {
-      "term" : "foo",
-      "count" : 2
-    }, {
-      "term" : "bar",
-      "count" : 2
-    }, {
-      "term" : "baz",
-      "count" : 1
-    }
-  ]
-}
-
my.Facet = Backbone.Model.extend({
+});

A Facet (Result)

my.Facet = Backbone.Model.extend({
   defaults: function() {
     return {
       _type: 'terms',
@@ -421,12 +419,12 @@ key used to specify this facet in the facet query):

terms: [] }; } -});

A Collection/List of Facets

my.FacetList = Backbone.Collection.extend({
+});

A Collection/List of Facets

my.FacetList = Backbone.Collection.extend({
   model: my.Facet
-});

Object State

+});

Object State

Convenience Backbone model for storing (configuration) state of objects like Views.

my.ObjectState = Backbone.Model.extend({
-});

Backbone.sync

+});

Backbone.sync

Override Backbone.sync to hand off to sync function in relevant backend

Backbone.sync = function(method, model, options) {
   return model.backend.sync(method, model, options);
diff --git a/docs/src/view.graph.html b/docs/src/view.graph.html
index 2f457a7f..29d11900 100644
--- a/docs/src/view.graph.html
+++ b/docs/src/view.graph.html
@@ -1,4 +1,4 @@
-      view-graph.js           

view-graph.js

/*jshint multistr:true */
+      view.graph.js           

view.graph.js

/*jshint multistr:true */
 
 this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
@@ -20,44 +20,10 @@
 
 

NB: should not provide an el argument to the view but must let the view generate the element itself (you can then append view.el to the DOM.

my.Graph = Backbone.View.extend({
-
   tagName:  "div",
   className: "recline-graph",
 
   template: ' \
-  <div class="editor"> \
-    <form class="form-stacked"> \
-      <div class="clearfix"> \
-        <label>Graph Type</label> \
-        <div class="input editor-type"> \
-          <select> \
-          <option value="lines-and-points">Lines and Points</option> \
-          <option value="lines">Lines</option> \
-          <option value="points">Points</option> \
-          <option value="bars">Bars</option> \
-          </select> \
-        </div> \
-        <label>Group Column (x-axis)</label> \
-        <div class="input editor-group"> \
-          <select> \
-          <option value="">Please choose ...</option> \
-          {{#fields}} \
-          <option value="{{id}}">{{label}}</option> \
-          {{/fields}} \
-          </select> \
-        </div> \
-        <div class="editor-series-group"> \
-        </div> \
-      </div> \
-      <div class="editor-buttons"> \
-        <button class="btn editor-add">Add Series</button> \
-      </div> \
-      <div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
-        <button class="editor-save">Save</button> \
-        <input type="hidden" class="editor-id" value="chart-1" /> \
-      </div> \
-    </form> \
-  </div> \
   <div class="panel graph"> \
     <div class="js-temp-notice alert alert-block"> \
       <h3 class="alert-heading">Hey there!</h3> \
@@ -67,26 +33,6 @@ generate the element itself (you can then append view.el to the DOM.

</div> \ </div> \ ', - templateSeriesEditor: ' \ - <div class="editor-series js-series-{{seriesIndex}}"> \ - <label>Series <span>{{seriesName}} (y-axis)</span> \ - [<a href="#remove" class="action-remove-series">Remove</a>] \ - </label> \ - <div class="input"> \ - <select> \ - {{#fields}} \ - <option value="{{id}}">{{label}}</option> \ - {{/fields}} \ - </select> \ - </div> \ - </div> \ - ', - - events: { - 'change form select': 'onEditorSubmit', - 'click .editor-add': '_onAddSeries', - 'click .action-remove-series': 'removeSeries' - }, initialize: function(options) { var self = this; @@ -96,8 +42,8 @@ generate the element itself (you can then append view.el to the DOM.

this.model.bind('change', this.render); this.model.fields.bind('reset', this.render); this.model.fields.bind('add', this.render); - this.model.currentDocuments.bind('add', this.redraw); - this.model.currentDocuments.bind('reset', this.redraw);

because we cannot redraw when hidden we may need when becoming visible

    this.bind('view:show', function() {
+    this.model.currentRecords.bind('add', this.redraw);
+    this.model.currentRecords.bind('reset', this.redraw);

because we cannot redraw when hidden we may need when becoming visible

    this.bind('view:show', function() {
       if (this.needToRedraw) {
         self.redraw();
       }
@@ -109,6 +55,15 @@ generate the element itself (you can then append view.el to the DOM.

options.state ); this.state = new recline.Model.ObjectState(stateData); + this.editor = new my.GraphControls({ + model: this.model, + state: this.state.toJSON() + }); + this.editor.state.bind('change', function() { + self.state.set(self.editor.state.toJSON()); + self.redraw(); + }); + this.elSidebar = this.editor.el; this.render(); }, @@ -117,77 +72,39 @@ generate the element itself (you can then append view.el to the DOM.

var tmplData = this.model.toTemplateJSON(); var htmls = Mustache.render(this.template, tmplData); $(this.el).html(htmls); - this.$graph = this.el.find('.panel.graph');

set up editor from state

    if (this.state.get('graphType')) {
-      this._selectOption('.editor-type', this.state.get('graphType'));
-    }
-    if (this.state.get('group')) {
-      this._selectOption('.editor-group', this.state.get('group'));
-    }

ensure at least one series box shows up

    var tmpSeries = [""];
-    if (this.state.get('series').length > 0) {
-      tmpSeries = this.state.get('series');
-    }
-    _.each(tmpSeries, function(series, idx) {
-      self.addSeries(idx);
-      self._selectOption('.editor-series.js-series-' + idx, series);
-    });
+    this.$graph = this.el.find('.panel.graph');
     return this;
-  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
-    var options = this.el.find(id + ' select > option');
-    if (options) {
-      options.each(function(opt){
-        if (this.value == value) {
-          $(this).attr('selected','selected');
-          return false;
-        }
-      });
-    }
   },
 
-  onEditorSubmit: function(e) {
-    var select = this.el.find('.editor-group select');
-    var $editor = this;
-    var $series  = this.el.find('.editor-series select');
-    var series = $series.map(function () {
-      return $(this).val();
-    });
-    var updatedState = {
-      series: $.makeArray(series),
-      group: this.el.find('.editor-group select').val(),
-      graphType: this.el.find('.editor-type select').val()
-    };
-    this.state.set(updatedState);
-    this.redraw();
-  },
-
-  redraw: function() {

There appear to be issues generating a Flot graph if either:

    + redraw: function() {

There appear to be issues generating a Flot graph if either:

  • The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with

    Uncaught Invalid dimensions for plot, width = 0, height = 0

  • There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
    var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
-    if ((!areWeVisible || this.model.currentDocuments.length === 0)) {
+    if ((!areWeVisible || this.model.currentRecords.length === 0)) {
       this.needToRedraw = true;
       return;
-    }

check we have something to plot

    if (this.state.get('group') && this.state.get('series')) {
+    }

check we have something to plot

    if (this.state.get('group') && this.state.get('series')) {

faff around with width because flot draws axes outside of the element width which means graph can get push down as it hits element next to it

      this.$graph.width(this.el.width() - 20);
       var series = this.createSeries();
       var options = this.getGraphOptions(this.state.attributes.graphType);
       this.plot = $.plot(this.$graph, series, options);
       this.setupTooltips();
     }
-  },

getGraphOptions

+ },

getGraphOptions

Get options for Flot Graph

needs to be function as can depend on state

@param typeId graphType id (lines, lines-and-points etc)

  getGraphOptions: function(typeId) { 
-    var self = this;

special tickformatter to show labels rather than numbers + var self = this;

special tickformatter to show labels rather than numbers TODO: we should really use tickFormatter and 1 interval ticks if (and only if) x-axis values are non-numeric However, that is non-trivial to work out from a dataset (datasets may have no field type info). Thus at present we only do this for bars.

    var tickFormatter = function (val) {
-      if (self.model.currentDocuments.models[val]) {
-        var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);

if the value was in fact a number we want that not the

        if (typeof(out) == 'number') {
+      if (self.model.currentRecords.models[val]) {
+        var out = self.model.currentRecords.models[val].get(self.state.attributes.group);

if the value was in fact a number we want that not the

        if (typeof(out) == 'number') {
           return val;
         } else {
           return out;
@@ -196,7 +113,7 @@ have no field type info). Thus at present we only do this for bars.

return val; }; - var xaxis = {};

check for time series on x-axis

    if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
+    var xaxis = {};

check for time series on x-axis

    if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
       xaxis.mode = 'time';
       xaxis.timeformat = '%y-%b';
     }
@@ -239,7 +156,7 @@ have no field type info). Thus at present we only do this for bars.

tickLength: 1, tickFormatter: tickFormatter, min: -0.5, - max: self.model.currentDocuments.length - 0.5 + max: self.model.currentRecords.length - 0.5 } } }; @@ -269,16 +186,16 @@ have no field type info). Thus at present we only do this for bars.

$("#flot-tooltip").remove(); var x = item.datapoint[0]; - var y = item.datapoint[1];

it's horizontal so we have to flip

          if (self.state.attributes.graphType === 'bars') {
+          var y = item.datapoint[1];

it's horizontal so we have to flip

          if (self.state.attributes.graphType === 'bars') {
             var _tmp = x;
             x = y;
             y = _tmp;
-          }

convert back from 'index' value on x-axis (e.g. in cases where non-number values)

          if (self.model.currentDocuments.models[x]) {
-            x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
+          }

convert back from 'index' value on x-axis (e.g. in cases where non-number values)

          if (self.model.currentRecords.models[x]) {
+            x = self.model.currentRecords.models[x].get(self.state.attributes.group);
           } else {
             x = x.toFixed(2);
           }
-          y = y.toFixed(2);

is it time series

          var xfield = self.model.fields.get(self.state.attributes.group);
+          y = y.toFixed(2);

is it time series

          var xfield = self.model.fields.get(self.state.attributes.group);
           var isDateTime = xfield.get('type') === 'date';
           if (isDateTime) {
             x = new Date(parseInt(x)).toLocaleDateString();
@@ -305,17 +222,20 @@ have no field type info). Thus at present we only do this for bars.

var series = []; _.each(this.state.attributes.series, function(field) { var points = []; - _.each(self.model.currentDocuments.models, function(doc, index) { + _.each(self.model.currentRecords.models, function(doc, index) { var xfield = self.model.fields.get(self.state.attributes.group); - var x = doc.getFieldValue(xfield);

time series

        var isDateTime = xfield.get('type') === 'date';
+        var x = doc.getFieldValue(xfield);

time series

        var isDateTime = xfield.get('type') === 'date';
         if (isDateTime) {
-          x = new Date(x);
+          x = moment(x).toDate();
         }
         var yfield = self.model.fields.get(field);
         var y = doc.getFieldValue(yfield);
         if (typeof x === 'string') {
-          x = index;
-        }

horizontal bar chart

        if (self.state.attributes.graphType == 'bars') {
+          x = parseFloat(x);
+          if (isNaN(x)) {
+            x = index;
+          }
+        }

horizontal bar chart

        if (self.state.attributes.graphType == 'bars') {
           points.push([y, x]);
         } else {
           points.push([x, y]);
@@ -324,7 +244,120 @@ have no field type info). Thus at present we only do this for bars.

series.push({data: points, label: field}); }); return series; - },

Public: Adds a new empty series select box to the editor.

+ } +}); + +my.GraphControls = Backbone.View.extend({ + className: "editor", + template: ' \ + <div class="editor"> \ + <form class="form-stacked"> \ + <div class="clearfix"> \ + <label>Graph Type</label> \ + <div class="input editor-type"> \ + <select> \ + <option value="lines-and-points">Lines and Points</option> \ + <option value="lines">Lines</option> \ + <option value="points">Points</option> \ + <option value="bars">Bars</option> \ + </select> \ + </div> \ + <label>Group Column (x-axis)</label> \ + <div class="input editor-group"> \ + <select> \ + <option value="">Please choose ...</option> \ + {{#fields}} \ + <option value="{{id}}">{{label}}</option> \ + {{/fields}} \ + </select> \ + </div> \ + <div class="editor-series-group"> \ + </div> \ + </div> \ + <div class="editor-buttons"> \ + <button class="btn editor-add">Add Series</button> \ + </div> \ + <div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \ + <button class="editor-save">Save</button> \ + <input type="hidden" class="editor-id" value="chart-1" /> \ + </div> \ + </form> \ + </div> \ +', + templateSeriesEditor: ' \ + <div class="editor-series js-series-{{seriesIndex}}"> \ + <label>Series <span>{{seriesName}} (y-axis)</span> \ + [<a href="#remove" class="action-remove-series">Remove</a>] \ + </label> \ + <div class="input"> \ + <select> \ + {{#fields}} \ + <option value="{{id}}">{{label}}</option> \ + {{/fields}} \ + </select> \ + </div> \ + </div> \ + ', + events: { + 'change form select': 'onEditorSubmit', + 'click .editor-add': '_onAddSeries', + 'click .action-remove-series': 'removeSeries' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.fields.bind('reset', this.render); + this.model.fields.bind('add', this.render); + this.state = new recline.Model.ObjectState(options.state); + this.render(); + }, + + render: function() { + var self = this; + var tmplData = this.model.toTemplateJSON(); + var htmls = Mustache.render(this.template, tmplData); + this.el.html(htmls);

set up editor from state

    if (this.state.get('graphType')) {
+      this._selectOption('.editor-type', this.state.get('graphType'));
+    }
+    if (this.state.get('group')) {
+      this._selectOption('.editor-group', this.state.get('group'));
+    }

ensure at least one series box shows up

    var tmpSeries = [""];
+    if (this.state.get('series').length > 0) {
+      tmpSeries = this.state.get('series');
+    }
+    _.each(tmpSeries, function(series, idx) {
+      self.addSeries(idx);
+      self._selectOption('.editor-series.js-series-' + idx, series);
+    });
+    return this;
+  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
+    var options = this.el.find(id + ' select > option');
+    if (options) {
+      options.each(function(opt){
+        if (this.value == value) {
+          $(this).attr('selected','selected');
+          return false;
+        }
+      });
+    }
+  },
+
+  onEditorSubmit: function(e) {
+    var select = this.el.find('.editor-group select');
+    var $editor = this;
+    var $series  = this.el.find('.editor-series select');
+    var series = $series.map(function () {
+      return $(this).val();
+    });
+    var updatedState = {
+      series: $.makeArray(series),
+      group: this.el.find('.editor-group select').val(),
+      graphType: this.el.find('.editor-type select').val()
+    };
+    this.state.set(updatedState);
+  },

Public: Adds a new empty series select box to the editor.

@param [int] idx index of this series in the list of series

@@ -342,7 +375,7 @@ have no field type info). Thus at present we only do this for bars.

_onAddSeries: function(e) { e.preventDefault(); this.addSeries(this.state.get('series').length); - },

Public: Removes a series list item from the editor.

+ },

Public: Removes a series list item from the editor.

Also updates the labels of the remaining series elements.

  removeSeries: function (e) {
     e.preventDefault();
diff --git a/docs/src/view.grid.html b/docs/src/view.grid.html
index af6d95e9..1720f5ae 100644
--- a/docs/src/view.grid.html
+++ b/docs/src/view.grid.html
@@ -1,4 +1,4 @@
-      view-grid.js           

view-grid.js

/*jshint multistr:true */
+      view.grid.js           

view.grid.js

/*jshint multistr:true */
 
 this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
@@ -15,9 +15,9 @@
     var self = this;
     this.el = $(this.el);
     _.bindAll(this, 'render', 'onHorizontalScroll');
-    this.model.currentDocuments.bind('add', this.render);
-    this.model.currentDocuments.bind('reset', this.render);
-    this.model.currentDocuments.bind('remove', this.render);
+    this.model.currentRecords.bind('add', this.render);
+    this.model.currentRecords.bind('reset', this.render);
+    this.model.currentRecords.bind('remove', this.render);
     this.tempState = {};
     var state = _.extend({
         hiddenFields: []
@@ -69,11 +69,11 @@ Column and row menus

showColumn: function() { self.showColumn(e); }, deleteRow: function() { var self = this; - var doc = _.find(self.model.currentDocuments.models, function(doc) {

important this is == as the currentRow will be string (as comes + var doc = _.find(self.model.currentRecords.models, function(doc) {

important this is == as the currentRow will be string (as comes from DOM) while id may be int

          return doc.id == self.tempState.currentRow;
         });
         doc.destroy().then(function() { 
-            self.model.currentDocuments.remove(doc);
+            self.model.currentRecords.remove(doc);
             self.trigger('recline:flash', {message: "Row deleted successfully"});
           }).fail(function(err) {
             self.trigger('recline:flash', {message: "Errorz! " + err});
@@ -187,7 +187,7 @@ from DOM) while id may be int

}); var htmls = Mustache.render(this.template, this.toTemplateJSON()); this.el.html(htmls); - this.model.currentDocuments.forEach(function(doc) { + this.model.currentRecords.forEach(function(doc) { var tr = $('<tr />'); self.el.find('tbody').append(tr); var newView = new my.GridRow({ @@ -213,7 +213,7 @@ from DOM) while id may be int

$c.remove(); return dim; } -});

GridRow View for rendering an individual document.

+});

GridRow View for rendering an individual record.

Since we want this to update in place it is up to creator to provider the element to attach to.

@@ -223,7 +223,7 @@ from DOM) while id may be int

var row = new GridRow({ - model: dataset-document, + model: dataset-record, el: dom-element, fields: mydatasets.fields // a FieldList object }); diff --git a/docs/src/view.map.html b/docs/src/view.map.html index bca8e38b..7c5cf607 100644 --- a/docs/src/view.map.html +++ b/docs/src/view.map.html @@ -1,11 +1,11 @@ - view-map.js

view-map.js

/*jshint multistr:true */
+      view.map.js           
});}} - - }); +});})(jQuery,recline.View); diff --git a/docs/src/view.multiview.html b/docs/src/view.multiview.html index 317e2ada..e9fdc237 100644 --- a/docs/src/view.multiview.html +++ b/docs/src/view.multiview.html @@ -1,109 +1,12 @@ -view.js

view.map.js

/*jshint multistr:true */
 
 this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
 
 (function($, my) {

Map view for a Dataset using Leaflet mapping library.

-

This view allows to plot gereferenced documents on a map. The location +

This view allows to plot gereferenced records on a map. The location information can be provided either via a field with GeoJSON objects or two fields with latitude and longitude coordinates.

@@ -21,12 +21,300 @@ have the following (optional) configuration options:

latField: {id of field containing latitude in the dataset} }
my.Map = Backbone.View.extend({
-
   tagName:  'div',
   className: 'recline-map',
 
   template: ' \
-  <div class="editor"> \
+    <div class="panel map"></div> \
+',

These are the default (case-insensitive) names of field that are used if found. +If not found, the user will need to define the fields via the editor.

  latitudeFieldNames: ['lat','latitude'],
+  longitudeFieldNames: ['lon','longitude'],
+  geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location'],
+
+  initialize: function(options) {
+    var self = this;
+    this.el = $(this.el);

Listen to changes in the fields

    this.model.fields.bind('change', function() {
+      self._setupGeometryField()
+      self.render()
+    });

Listen to changes in the records

    this.model.currentRecords.bind('add', function(doc){self.redraw('add',doc)});
+    this.model.currentRecords.bind('change', function(doc){
+        self.redraw('remove',doc);
+        self.redraw('add',doc);
+    });
+    this.model.currentRecords.bind('remove', function(doc){self.redraw('remove',doc)});
+    this.model.currentRecords.bind('reset', function(){self.redraw('reset')});
+
+    this.bind('view:show',function(){

If the div was hidden, Leaflet needs to recalculate some sizes +to display properly

      if (self.map){
+        self.map.invalidateSize();
+        if (self._zoomPending && self.state.get('autoZoom')) {
+          self._zoomToFeatures();
+          self._zoomPending = false;
+        }
+      }
+      self.visible = true;
+    });
+    this.bind('view:hide',function(){
+      self.visible = false;
+    });
+
+    var stateData = _.extend({
+        geomField: null,
+        lonField: null,
+        latField: null,
+        autoZoom: true
+      },
+      options.state
+    );
+    this.state = new recline.Model.ObjectState(stateData);
+    this.menu = new my.MapMenu({
+      model: this.model,
+      state: this.state.toJSON()
+    });
+    this.menu.state.bind('change', function() {
+      self.state.set(self.menu.state.toJSON());
+      self.redraw();
+    });
+    this.elSidebar = this.menu.el;
+
+    this.mapReady = false;
+    this.render();
+    this.redraw();
+  },

Public: Adds the necessary elements to the page.

+ +

Also sets up the editor fields and the map if necessary.

  render: function() {
+    var self = this;
+
+    htmls = Mustache.render(this.template, this.model.toTemplateJSON());
+    $(this.el).html(htmls);
+    this.$map = this.el.find('.panel.map');
+    return this;
+  },

Public: Redraws the features on the map according to the action provided

+ +

Actions can be:

+ +
    +
  • reset: Clear all features
  • +
  • add: Add one or n features (records)
  • +
  • remove: Remove one or n features (records)
  • +
  • refresh: Clear existing features and add all current records
  • +
  redraw: function(action, doc){
+    var self = this;
+    action = action || 'refresh';

try to set things up if not already

    if (!self._geomReady()){
+      self._setupGeometryField();
+    }
+    if (!self.mapReady){
+      self._setupMap();
+    }
+
+    if (this._geomReady() && this.mapReady){
+      if (action == 'reset' || action == 'refresh'){
+        this.features.clearLayers();
+        this._add(this.model.currentRecords.models);
+      } else if (action == 'add' && doc){
+        this._add(doc);
+      } else if (action == 'remove' && doc){
+        this._remove(doc);
+      }
+      if (this.state.get('autoZoom')){
+        if (this.visible){
+          this._zoomToFeatures();
+        } else {
+          this._zoomPending = true;
+        }
+      }
+    }
+  },
+
+  _geomReady: function() {
+    return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
+  },

Private: Add one or n features to the map

+ +

For each record passed, a GeoJSON geometry will be extracted and added +to the features layer. If an exception is thrown, the process will be +stopped and an error notification shown.

+ +

Each feature will have a popup associated with all the record fields.

  _add: function(docs){
+    var self = this;
+
+    if (!(docs instanceof Array)) docs = [docs];
+
+    var count = 0;
+    var wrongSoFar = 0;
+    _.every(docs,function(doc){
+      count += 1;
+      var feature = self._getGeometryFromRecord(doc);
+      if (typeof feature === 'undefined' || feature === null){

Empty field

        return true;
+      } else if (feature instanceof Object){

Build popup contents +TODO: mustache?

        html = ''
+        for (key in doc.attributes){
+          if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
+            html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
+          }
+        }
+        feature.properties = {popupContent: html};

Add a reference to the model id, which will allow us to +link this Leaflet layer to a Recline doc

        feature.properties.cid = doc.cid;
+
+        try {
+          self.features.addGeoJSON(feature);
+        } catch (except) {
+          wrongSoFar += 1;
+          var msg = 'Wrong geometry value';
+          if (except.message) msg += ' (' + except.message + ')';
+          if (wrongSoFar <= 10) {
+            self.trigger('recline:flash', {message: msg, category:'error'});
+          }
+        }
+      } else {
+        wrongSoFar += 1
+        if (wrongSoFar <= 10) {
+          self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'});
+        }
+      }
+      return true;
+    });
+  },

Private: Remove one or n features to the map

  _remove: function(docs){
+
+    var self = this;
+
+    if (!(docs instanceof Array)) docs = [docs];
+
+    _.each(docs,function(doc){
+      for (key in self.features._layers){
+        if (self.features._layers[key].cid == doc.cid){
+          self.features.removeLayer(self.features._layers[key]);
+        }
+      }
+    });
+
+  },

Private: Return a GeoJSON geomtry extracted from the record fields

  _getGeometryFromRecord: function(doc){
+    if (this.state.get('geomField')){
+      var value = doc.get(this.state.get('geomField'));
+      if (typeof(value) === 'string'){

We may have a GeoJSON string representation

        try {
+          value = $.parseJSON(value);
+        } catch(e) {}
+      }
+
+      if (typeof(value) === 'string') {
+        value = value.replace('(', '').replace(')', '');
+        var parts = value.split(',');
+        var lat = parseFloat(parts[0]);
+        var lon = parseFloat(parts[1]);
+        if (!isNaN(lon) && !isNaN(parseFloat(lat))) {
+          return {
+            "type": "Point",
+            "coordinates": [lon, lat]
+          };
+        } else {
+          return null;
+        }
+      } else if (value && value.slice) {

[ lon, lat ]

        return {
+          "type": "Point",
+          "coordinates": [value[0], value[1]]
+        };
+      } else if (value && value.lat) {

of form { lat: ..., lon: ...}

        return {
+          "type": "Point",
+          "coordinates": [value.lon || value.lng, value.lat]
+        };
+      }

We o/w assume that contents of the field are a valid GeoJSON object

      return value;
+    } else if (this.state.get('lonField') && this.state.get('latField')){

We'll create a GeoJSON like point object from the two lat/lon fields

      var lon = doc.get(this.state.get('lonField'));
+      var lat = doc.get(this.state.get('latField'));
+      if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
+        return {
+          type: 'Point',
+          coordinates: [lon,lat]
+        };
+      }
+    }
+    return null;
+  },

Private: Check if there is a field with GeoJSON geometries or alternatively, +two fields with lat/lon values.

+ +

If not found, the user can define them via the UI form.

  _setupGeometryField: function(){

should not overwrite if we have already set this (e.g. explicitly via state)

    if (!this._geomReady()) {
+      this.state.set({
+        geomField: this._checkField(this.geometryFieldNames),
+        latField: this._checkField(this.latitudeFieldNames),
+        lonField: this._checkField(this.longitudeFieldNames)
+      });
+      this.menu.state.set(this.state.toJSON());
+    }
+  },

Private: Check if a field in the current model exists in the provided +list of names.

  _checkField: function(fieldNames){
+    var field;
+    var modelFieldNames = this.model.fields.pluck('id');
+    for (var i = 0; i < fieldNames.length; i++){
+      for (var j = 0; j < modelFieldNames.length; j++){
+        if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase())
+          return modelFieldNames[j];
+      }
+    }
+    return null;
+  },

Private: Zoom to map to current features extent if any, or to the full +extent if none.

  _zoomToFeatures: function(){
+    var bounds = this.features.getBounds();
+    if (bounds){
+      this.map.fitBounds(bounds);
+    } else {
+      this.map.setView(new L.LatLng(0, 0), 2);
+    }
+  },

Private: Sets up the Leaflet map control and the features layer.

+ +

The map uses a base layer from MapQuest based +on OpenStreetMap.

  _setupMap: function(){
+    this.map = new L.Map(this.$map.get(0));
+
+    var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
+    var osmAttribution = 'Map data &copy; 2011 OpenStreetMap contributors, Tiles Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png">';
+    var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
+    this.map.addLayer(bg);
+
+    this.features = new L.GeoJSON();
+    this.features.on('featureparse', function (e) {
+      if (e.properties && e.properties.popupContent){
+        e.layer.bindPopup(e.properties.popupContent);
+       }
+      if (e.properties && e.properties.cid){
+        e.layer.cid = e.properties.cid;
+       }
+
+    });

This will be available in the next Leaflet stable release. +In the meantime we add it manually to our layer.

    this.features.getBounds = function(){
+      var bounds = new L.LatLngBounds();
+      this._iterateLayers(function (layer) {
+        if (layer instanceof L.Marker){
+          bounds.extend(layer.getLatLng());
+        } else {
+          if (layer.getBounds){
+            bounds.extend(layer.getBounds().getNorthEast());
+            bounds.extend(layer.getBounds().getSouthWest());
+          }
+        }
+      }, this);
+      return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
+    }
+
+    this.map.addLayer(this.features);
+
+    this.map.setView(new L.LatLng(0, 0), 2);
+
+    this.mapReady = true;
+  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
+    var options = $('.' + id + ' > select > option');
+    if (options){
+      options.each(function(opt){
+        if (this.value == value) {
+          $(this).attr('selected','selected');
+          return false;
+        }
+      });
+    }
+  }
+});
+
+my.MapMenu = Backbone.View.extend({
+  className: 'editor',
+
+  template: ' \
     <form class="form-stacked"> \
       <div class="clearfix"> \
         <div class="editor-field-type"> \
@@ -80,309 +368,82 @@ have the following (optional) configuration options:

<input type="hidden" class="editor-id" value="map-1" /> \ </div> \ </form> \ - </div> \ -<div class="panel map"> \ -</div> \ -',

These are the default (case-insensitive) names of field that are used if found. -If not found, the user will need to define the fields via the editor.

  latitudeFieldNames: ['lat','latitude'],
-  longitudeFieldNames: ['lon','longitude'],
-  geometryFieldNames: ['geom','the_geom','geometry','spatial','location'],

Define here events for UI elements

  events: {
+',

Define here events for UI elements

  events: {
     'click .editor-update-map': 'onEditorSubmit',
     'change .editor-field-type': 'onFieldTypeChange',
-    'change #editor-auto-zoom': 'onAutoZoomChange'
+    'click #editor-auto-zoom': 'onAutoZoomChange'
   },
 
   initialize: function(options) {
     var self = this;
-    this.el = $(this.el);

Listen to changes in the fields

    this.model.fields.bind('change', function() {
-      self._setupGeometryField();
-    });
-    this.model.fields.bind('add', this.render);
-    this.model.fields.bind('reset', function(){
-      self._setupGeometryField()
-      self.render()
-    });

Listen to changes in the documents

    this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)});
-    this.model.currentDocuments.bind('change', function(doc){
-        self.redraw('remove',doc);
-        self.redraw('add',doc);
-    });
-    this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)});
-    this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});
-
-    this.bind('view:show',function(){

If the div was hidden, Leaflet needs to recalculate some sizes -to display properly

      if (self.map){
-        self.map.invalidateSize();
-        if (self._zoomPending && self.autoZoom) {
-          self._zoomToFeatures();
-          self._zoomPending = false;
-        }
-      }
-      self.visible = true;
-    });
-    this.bind('view:hide',function(){
-      self.visible = false;
-    });
-
-    var stateData = _.extend({
-        geomField: null,
-        lonField: null,
-        latField: null
-      },
-      options.state
-    );
-    this.state = new recline.Model.ObjectState(stateData);
-
-    this.autoZoom = true;
-    this.mapReady = false;
+    this.el = $(this.el);
+    _.bindAll(this, 'render');
+    this.model.fields.bind('change', this.render);
+    this.state = new recline.Model.ObjectState(options.state);
+    this.state.bind('change', this.render);
     this.render();
-  },

Public: Adds the necessary elements to the page.

+ },

Public: Adds the necessary elements to the page.

Also sets up the editor fields and the map if necessary.

  render: function() {
-
     var self = this;
-
     htmls = Mustache.render(this.template, this.model.toTemplateJSON());
-
     $(this.el).html(htmls);
-    this.$map = this.el.find('.panel.map');
 
-    if (this.geomReady && this.model.fields.length){
+    if (this._geomReady() && this.model.fields.length){
       if (this.state.get('geomField')){
         this._selectOption('editor-geom-field',this.state.get('geomField'));
-        $('#editor-field-type-geom').attr('checked','checked').change();
+        this.el.find('#editor-field-type-geom').attr('checked','checked').change();
       } else{
         this._selectOption('editor-lon-field',this.state.get('lonField'));
         this._selectOption('editor-lat-field',this.state.get('latField'));
-        $('#editor-field-type-latlon').attr('checked','checked').change();
+        this.el.find('#editor-field-type-latlon').attr('checked','checked').change();
       }
     }
+    if (this.state.get('autoZoom')) {
+      this.el.find('#editor-auto-zoom').attr('checked', 'checked');
+    }
+    else {
+      this.el.find('#editor-auto-zoom').removeAttr('checked');
+    }
     return this;
-  },

Public: Redraws the features on the map according to the action provided

+ }, -

Actions can be:

- -
    -
  • reset: Clear all features
  • -
  • add: Add one or n features (documents)
  • -
  • remove: Remove one or n features (documents)
  • -
  • refresh: Clear existing features and add all current documents
  • -
  redraw: function(action, doc){
-    var self = this;
-    action = action || 'refresh';

try to set things up if not already

    if (!self.geomReady){
-      self._setupGeometryField();
-    }
-    if (!self.mapReady){
-      self._setupMap();
-    }
-
-    if (this.geomReady && this.mapReady){
-      if (action == 'reset' || action == 'refresh'){
-        this.features.clearLayers();
-        this._add(this.model.currentDocuments.models);
-      } else if (action == 'add' && doc){
-        this._add(doc);
-      } else if (action == 'remove' && doc){
-        this._remove(doc);
-      }
-      if (this.autoZoom){
-        if (this.visible){
-          this._zoomToFeatures();
-        } else {
-          this._zoomPending = true;
-        }
-      }
-    }
-  },

UI Event handlers

Public: Update map with user options

+ _geomReady: function() { + return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); + },

UI Event handlers

Public: Update map with user options

Right now the only configurable option is what field(s) contains the location information.

  onEditorSubmit: function(e){
     e.preventDefault();
-    if ($('#editor-field-type-geom').attr('checked')){
+    if (this.el.find('#editor-field-type-geom').attr('checked')){
       this.state.set({
-        geomField: $('.editor-geom-field > select > option:selected').val(),
+        geomField: this.el.find('.editor-geom-field > select > option:selected').val(),
         lonField: null,
         latField: null
       });
     } else {
       this.state.set({
         geomField: null,
-        lonField: $('.editor-lon-field > select > option:selected').val(),
-        latField: $('.editor-lat-field > select > option:selected').val()
+        lonField: this.el.find('.editor-lon-field > select > option:selected').val(),
+        latField: this.el.find('.editor-lat-field > select > option:selected').val()
       });
     }
-    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
-    this.redraw();
-
     return false;
-  },

Public: Shows the relevant select lists depending on the location field + },

Public: Shows the relevant select lists depending on the location field type selected.

  onFieldTypeChange: function(e){
     if (e.target.value == 'geom'){
-        $('.editor-field-type-geom').show();
-        $('.editor-field-type-latlon').hide();
+        this.el.find('.editor-field-type-geom').show();
+        this.el.find('.editor-field-type-latlon').hide();
     } else {
-        $('.editor-field-type-geom').hide();
-        $('.editor-field-type-latlon').show();
+        this.el.find('.editor-field-type-geom').hide();
+        this.el.find('.editor-field-type-latlon').show();
     }
   },
 
   onAutoZoomChange: function(e){
-    this.autoZoom = !this.autoZoom;
-  },

Private: Add one or n features to the map

- -

For each document passed, a GeoJSON geometry will be extracted and added -to the features layer. If an exception is thrown, the process will be -stopped and an error notification shown.

- -

Each feature will have a popup associated with all the document fields.

  _add: function(docs){
-    var self = this;
-
-    if (!(docs instanceof Array)) docs = [docs];
-
-    var count = 0;
-    var wrongSoFar = 0;
-    _.every(docs,function(doc){
-      count += 1;
-      var feature = self._getGeometryFromDocument(doc);
-      if (typeof feature === 'undefined' || feature === null){

Empty field

        return true;
-      } else if (feature instanceof Object){

Build popup contents -TODO: mustache?

        html = ''
-        for (key in doc.attributes){
-          if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
-            html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
-          }
-        }
-        feature.properties = {popupContent: html};

Add a reference to the model id, which will allow us to -link this Leaflet layer to a Recline doc

        feature.properties.cid = doc.cid;
-
-        try {
-          self.features.addGeoJSON(feature);
-        } catch (except) {
-          wrongSoFar += 1;
-          var msg = 'Wrong geometry value';
-          if (except.message) msg += ' (' + except.message + ')';
-          if (wrongSoFar <= 10) {
-            self.trigger('recline:flash', {message: msg, category:'error'});
-          }
-        }
-      } else {
-        wrongSoFar += 1
-        if (wrongSoFar <= 10) {
-          self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'});
-        }
-      }
-      return true;
-    });
-  },

Private: Remove one or n features to the map

  _remove: function(docs){
-
-    var self = this;
-
-    if (!(docs instanceof Array)) docs = [docs];
-
-    _.each(docs,function(doc){
-      for (key in self.features._layers){
-        if (self.features._layers[key].cid == doc.cid){
-          self.features.removeLayer(self.features._layers[key]);
-        }
-      }
-    });
-
-  },

Private: Return a GeoJSON geomtry extracted from the document fields

  _getGeometryFromDocument: function(doc){
-    if (this.geomReady){
-      if (this.state.get('geomField')){
-        var value = doc.get(this.state.get('geomField'));
-        if (typeof(value) === 'string'){

We may have a GeoJSON string representation

          try {
-            return $.parseJSON(value);
-          } catch(e) {
-          }
-        } else {

We assume that the contents of the field are a valid GeoJSON object

          return value;
-        }
-      } else if (this.state.get('lonField') && this.state.get('latField')){

We'll create a GeoJSON like point object from the two lat/lon fields

        var lon = doc.get(this.state.get('lonField'));
-        var lat = doc.get(this.state.get('latField'));
-        if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
-          return {
-            type: 'Point',
-            coordinates: [lon,lat]
-          };
-        }
-      }
-      return null;
-    }
-  },

Private: Check if there is a field with GeoJSON geometries or alternatively, -two fields with lat/lon values.

- -

If not found, the user can define them via the UI form.

  _setupGeometryField: function(){
-    var geomField, latField, lonField;
-    this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));

should not overwrite if we have already set this (e.g. explicitly via state)

    if (!this.geomReady) {
-      this.state.set({
-        geomField: this._checkField(this.geometryFieldNames),
-        latField: this._checkField(this.latitudeFieldNames),
-        lonField: this._checkField(this.longitudeFieldNames)
-      });
-      this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
-    }
-  },

Private: Check if a field in the current model exists in the provided -list of names.

  _checkField: function(fieldNames){
-    var field;
-    var modelFieldNames = this.model.fields.pluck('id');
-    for (var i = 0; i < fieldNames.length; i++){
-      for (var j = 0; j < modelFieldNames.length; j++){
-        if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase())
-          return modelFieldNames[j];
-      }
-    }
-    return null;
-  },

Private: Zoom to map to current features extent if any, or to the full -extent if none.

  _zoomToFeatures: function(){
-    var bounds = this.features.getBounds();
-    if (bounds){
-      this.map.fitBounds(bounds);
-    } else {
-      this.map.setView(new L.LatLng(0, 0), 2);
-    }
-  },

Private: Sets up the Leaflet map control and the features layer.

- -

The map uses a base layer from MapQuest based -on OpenStreetMap.

  _setupMap: function(){
-
-    this.map = new L.Map(this.$map.get(0));
-
-    var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
-    var osmAttribution = 'Map data &copy; 2011 OpenStreetMap contributors, Tiles Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png">';
-    var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
-    this.map.addLayer(bg);
-
-    this.features = new L.GeoJSON();
-    this.features.on('featureparse', function (e) {
-      if (e.properties && e.properties.popupContent){
-        e.layer.bindPopup(e.properties.popupContent);
-       }
-      if (e.properties && e.properties.cid){
-        e.layer.cid = e.properties.cid;
-       }
-
-    });

This will be available in the next Leaflet stable release. -In the meantime we add it manually to our layer.

    this.features.getBounds = function(){
-      var bounds = new L.LatLngBounds();
-      this._iterateLayers(function (layer) {
-        if (layer instanceof L.Marker){
-          bounds.extend(layer.getLatLng());
-        } else {
-          if (layer.getBounds){
-            bounds.extend(layer.getBounds().getNorthEast());
-            bounds.extend(layer.getBounds().getSouthWest());
-          }
-        }
-      }, this);
-      return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
-    }
-
-    this.map.addLayer(this.features);
-
-    this.map.setView(new L.LatLng(0, 0), 2);
-
-    this.mapReady = true;
-  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
-    var options = $('.' + id + ' > select > option');
+    this.state.set({autoZoom: !this.state.get('autoZoom')});
+  },

Private: Helper function to select an option from a select list

  _selectOption: function(id,value){
+    var options = this.el.find('.' + id + ' > select > option');
     if (options){
       options.each(function(opt){
         if (this.value == value) {
@@ -392,8 +453,7 @@ In the meantime we add it manually to our layer.

view.js

/*jshint multistr:true */

Recline Views

- -

Recline Views are instances of Backbone Views and they act as 'WUI' (web -user interface) component displaying some model object in the DOM. Like all -Backbone views they have a pointer to a model (or a collection) and have an -associated DOM-style element (usually this element will be bound into the -page at some point).

- -

Views provided by core Recline are crudely divided into two types:

- -
    -
  • Dataset Views: a View intended for displaying a recline.Model.Dataset -in some fashion. Examples are the Grid, Graph and Map views.
  • -
  • Widget Views: a widget used for displaying some specific (and -smaller) aspect of a dataset or the application. Examples are -QueryEditor and FilterEditor which both provide a way for editing (a -part of) a recline.Model.Query associated to a Dataset.
  • -
- -

Dataset View

- -

These views are just Backbone views with a few additional conventions:

- -
    -
  1. The model passed to the View should always be a recline.Model.Dataset instance
  2. -
  3. Views should generate their own root element rather than having it passed -in.
  4. -
  5. Views should apply a css class named 'recline-{view-name-lower-cased} to -the root element (and for all CSS for this view to be qualified using this -CSS class)
  6. -
  7. Read-only mode: CSS for this view should respect/utilize -recline-read-only class to trigger read-only behaviour (this class will -usually be set on some parent element of the view's root element.
  8. -
  9. State: state (configuration) information for the view should be stored on -an attribute named state that is an instance of a Backbone Model (or, more -speficially, be an instance of recline.Model.ObjectState). In addition, -a state attribute may be specified in the Hash passed to a View on -iniitialization and this information should be used to set the initial -state of the view.

    - -

    Example of state would be the set of fields being plotted in a graph -view.

    - -

    More information about State can be found below.

  10. -
- -

To summarize some of this, the initialize function for a Dataset View should -look like:

- -
-   initialize: {
-       model: {a recline.Model.Dataset instance}
-       // el: {do not specify - instead view should create}
-       state: {(optional) Object / Hash specifying initial state}
-       ...
-   }
-
- -

Note: Dataset Views in core Recline have a common layout on disk as -follows, where ViewName is the named of View class:

- -
-src/view-{lower-case-ViewName}.js
-css/{lower-case-ViewName}.css
-test/view-{lower-case-ViewName}.js
-
- -

State

- -

State information exists in order to support state serialization into the -url or elsewhere and reloading of application from a stored state.

- -

State is available not only for individual views (as described above) but -for the dataset (e.g. the current query). For an example of pulling together -state from across multiple components see recline.View.DataExplorer.

- -

Flash Messages / Notifications

- -

To send 'flash messages' or notifications the convention is that views -should fire an event named recline:flash with a payload that is a -flash object with the following attributes (all optional):

- -
    -
  • message: message to show.
  • -
  • category: warning (default), success, error
  • -
  • persist: if true alert is persistent, o/w hidden after 3s (default=false)
  • -
  • loader: if true show a loading message
  • -
- -

Objects or views wishing to bind to flash messages may then subscribe to -these events and take some action such as displaying them to the user. For -an example of such behaviour see the DataExplorer view.

- -

Writing your own Views

- -

See the existing Views.

- -

Standard JS module setup

this.recline = this.recline || {};
+      view.multiview.js           
vartmplData=this.model.toTemplateJSON();tmplData.views=this.pageViews;vartemplate=Mustache.render(this.template,tmplData); - $(this.el).html(template); - var$dataViewContainer=this.el.find('.data-view-container'); - _.each(this.pageViews,function(view,pageName){ + $(this.el).html(template);e.preventDefault();varaction=$(e.target).attr('data-action');if(action==='filters'){ - this.$filterEditor.show(); - }elseif(action==='facets'){ - this.$facetViewer.show(); + this.$filterEditor.toggle(); + }elseif(action==='fields'){ + this.$fieldsView.toggle();}}, @@ -329,15 +250,15 @@ note this.model and dataset returned are the same

varviewName=$(e.target).attr('data-view');this.updateNav(viewName);this.state.set({currentView:viewName}); - },},_bindStateChanges:function(){ - varself=this;self.state.set(update);pageView.view.state.bind('change',function(){varupdate={}; - update['view-'+pageView.id]=pageView.view.state.toJSON();self.notify(flash);});}); - },

view.multiview.js

/*jshint multistr:true */

Standard JS module setup

this.recline = this.recline || {};
 this.recline.View = this.recline.View || {};
 
-(function($, my) {

DataExplorer

+(function($, my) {

MultiView

-

The primary view for the entire application. Usage:

+

Manage multiple views together along with query editor etc. Usage:

-var myExplorer = new model.recline.DataExplorer({
+var myExplorer = new model.recline.MultiView({
   model: {{recline.Model.Dataset instance}}
   el: {{an existing dom element}}
   views: {{dataset views}}
@@ -120,7 +23,7 @@ being in the DOM is important for rendering of some subviews (e.g.
 Graph).

views: (optional) the dataset views (Grid, Graph etc) for -DataExplorer to show. This is an array of view hashes. If not provided +MultiView to show. This is an array of view hashes. If not provided initialize with (recline.View.)Grid, Graph, and Map views (with obvious id and labels!).

@@ -161,27 +64,32 @@ state = {

Note that at present we do not serialize information about the actual set of views in use -- e.g. those specified by the views argument -- but instead expect either that the default views are fine or that the client to have -initialized the DataExplorer with the relevant views themselves.

my.DataExplorer = Backbone.View.extend({
+initialized the MultiView with the relevant views themselves.

my.MultiView = Backbone.View.extend({
   template: ' \
   <div class="recline-data-explorer"> \
     <div class="alert-messages"></div> \
     \
     <div class="header"> \
-      <ul class="navigation"> \
+      <div class="navigation"> \
+        <div class="btn-group" data-toggle="buttons-radio"> \
         {{#views}} \
-        <li><a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
+        <a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
         {{/views}} \
-      </ul> \
+        </div> \
+      </div> \
       <div class="recline-results-info"> \
         Results found <span class="doc-count">{{docCount}}</span> \
       </div> \
       <div class="menu-right"> \
-        <a href="#" class="btn" data-action="filters">Filters</a> \
-        <a href="#" class="btn" data-action="facets">Facets</a> \
+        <div class="btn-group" data-toggle="buttons-checkbox"> \
+          <a href="#" class="btn active" data-action="filters">Filters</a> \
+          <a href="#" class="btn active" data-action="fields">Fields</a> \
+        </div> \
       </div> \
       <div class="query-editor-here" style="display:inline;"></div> \
       <div class="clearfix"></div> \
     </div> \
+    <div class="data-view-sidebar"></div> \
     <div class="data-view-container"></div> \
   </div> \
   ',
@@ -193,7 +101,7 @@ initialized the DataExplorer with the relevant views themselves.

initialize: function(options) { var self = this; this.el = $(this.el); - this._setupState(options.state);

Hash of 'page' views (i.e. those for whole page) keyed by page name

    if (options.views) {
+    this._setupState(options.state);

Hash of 'page' views (i.e. those for whole page) keyed by page name

    if (options.views) {
       this.pageViews = options.views;
     } else {
       this.pageViews = [{
@@ -225,9 +133,9 @@ initialized the DataExplorer with the relevant views themselves.

state: this.state.get('view-timeline') }), }]; - }

these must be called after pageViews are created

    this.render();
+    }

these must be called after pageViews are created

    this.render();
     this._bindStateChanges();
-    this._bindFlashNotifications();

now do updates based on state (need to come after render)

    if (this.state.get('readOnly')) {
+    this._bindFlashNotifications();

now do updates based on state (need to come after render)

    if (this.state.get('readOnly')) {
       this.setReadOnly();
     }
     if (this.state.get('currentView')) {
@@ -259,11 +167,10 @@ initialized the DataExplorer with the relevant views themselves.

msg = 'There was an error querying the backend'; } self.notify({message: msg, category: 'error', persist: true}); - });

retrieve basic data like fields etc -note this.model and dataset returned are the same

    this.model.fetch()
-      .done(function(dataset) {
-        self.model.query(self.state.get('query'));
-      })
+      });

retrieve basic data like fields etc +note this.model and dataset returned are the same +TODO: set query state ...?

    this.model.queryState.set(self.state.get('query'), {silent: true});
+    this.model.fetch()
       .fail(function(error) {
         self.notify({message: error.message, category: 'error', persist: true});
       });
@@ -277,38 +184,52 @@ note this.model and dataset returned are the same

now create and append other views

    var $dataViewContainer = this.el.find('.data-view-container');
+    var $dataSidebar = this.el.find('.data-view-sidebar');

the main views

    _.each(this.pageViews, function(view, pageName) {
       $dataViewContainer.append(view.view.el);
+      if (view.view.elSidebar) {
+        $dataSidebar.append(view.view.elSidebar);
+      }
     });
-    var queryEditor = new my.QueryEditor({
+
+    var pager = new recline.View.Pager({
+      model: this.model.queryState
+    });
+    this.el.find('.recline-results-info').after(pager.el);
+
+    var queryEditor = new recline.View.QueryEditor({
       model: this.model.queryState
     });
     this.el.find('.query-editor-here').append(queryEditor.el);
-    var filterEditor = new my.FilterEditor({
-      model: this.model.queryState
-    });
-    this.$filterEditor = filterEditor.el;
-    this.el.find('.header').append(filterEditor.el);
-    var facetViewer = new my.FacetViewer({
+
+    var filterEditor = new recline.View.FilterEditor({
       model: this.model
     });
-    this.$facetViewer = facetViewer.el;
-    this.el.find('.header').append(facetViewer.el);
+    this.$filterEditor = filterEditor.el;
+    $dataSidebar.append(filterEditor.el);
+
+    var fieldsView = new recline.View.Fields({
+      model: this.model
+    });
+    this.$fieldsView = fieldsView.el;
+    $dataSidebar.append(fieldsView.el);
   },
 
   updateNav: function(pageName) {
-    this.el.find('.navigation li').removeClass('active');
-    this.el.find('.navigation li a').removeClass('disabled');
-    var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]');
-    $el.parent().addClass('active');
-    $el.addClass('disabled');

show the specific page

    _.each(this.pageViews, function(view, idx) {
+    this.el.find('.navigation a').removeClass('active');
+    var $el = this.el.find('.navigation a[data-view="' + pageName + '"]');
+    $el.addClass('active');

show the specific page

    _.each(this.pageViews, function(view, idx) {
       if (view.id === pageName) {
         view.view.el.show();
+        if (view.view.elSidebar) {
+          view.view.elSidebar.show();
+        }
         view.view.trigger('view:show');
       } else {
         view.view.el.hide();
+        if (view.view.elSidebar) {
+          view.view.elSidebar.hide();
+        }
         view.view.trigger('view:hide');
       }
     });
@@ -318,9 +239,9 @@ note this.model and dataset returned are the same

create a state object for this view and do the job of

+ },

create a state object for this view and do the job of

a) initializing it from both data passed in and other sources (e.g. hash url)

b) ensure the state object is updated in responese to changes in subviews, query etc.

  _setupState: function(initialState) {
-    var self = this;

get data from the query string / hash url plus some defaults

    var qs = recline.Util.parseHashQueryString();
+    var self = this;

get data from the query string / hash url plus some defaults

    var qs = my.parseHashQueryString();
     var query = qs.reclineQuery;
-    query = query ? JSON.parse(query) : self.model.queryState.toJSON();

backwards compatability (now named view-graph but was named graph)

    var graphState = qs['view-graph'] || qs.graph;
-    graphState = graphState ? JSON.parse(graphState) : {};

now get default data + hash url plus initial state and initial our state object with it

    var stateData = _.extend({
+    query = query ? JSON.parse(query) : self.model.queryState.toJSON();

backwards compatability (now named view-graph but was named graph)

    var graphState = qs['view-graph'] || qs.graph;
+    graphState = graphState ? JSON.parse(graphState) : {};

now get default data + hash url plus initial state and initial our state object with it

    var stateData = _.extend({
         query: query,
         'view-graph': graphState,
         backend: this.model.backend.__type__,
@@ -350,7 +271,7 @@ note this.model and dataset returned are the same

finally ensure we update our state object when state of sub-object changes so that state is always up to date

    this.model.queryState.bind('change', function() {
+    var self = this;

finally ensure we update our state object when state of sub-object changes so that state is always up to date

    this.model.queryState.bind('change', function() {
       self.state.set({query: self.model.queryState.toJSON()});
     });
     _.each(this.pageViews, function(pageView) {
@@ -360,7 +281,7 @@ note this.model and dataset returned are the same

had problems where change not being triggered for e.g. grid view so let's do it explicitly

          self.state.set(update, {silent: true});
+          update['view-' + pageView.id] = pageView.view.state.toJSON();

had problems where change not being triggered for e.g. grid view so let's do it explicitly

          self.state.set(update, {silent: true});
           self.state.trigger('change');
         });
       }
@@ -374,7 +295,7 @@ note this.model and dataset returned are the same

notify

+ },

notify

Create a notification (a div.alert in div.alert-messsages) using provided flash object. Flash attributes (all are optional):

@@ -413,7 +334,7 @@ flash object. Flash attributes (all are optional):

}); }, 1000); } - },

clearNotifications

+ },

clearNotifications

Clear all existing notifications

  clearNotifications: function() {
     var $notifications = $('.recline-data-explorer .alert-messages .alert');
@@ -421,236 +342,68 @@ flash object. Flash attributes (all are optional):

$(this).remove(); }); } -});

DataExplorer.restore

+});

MultiView.restore

-

Restore a DataExplorer instance from a serialized state including the associated dataset

my.DataExplorer.restore = function(state) {
+

Restore a MultiView instance from a serialized state including the associated dataset

my.MultiView.restore = function(state) {
   var dataset = recline.Model.Dataset.restore(state);
-  var explorer = new my.DataExplorer({
+  var explorer = new my.MultiView({
     model: dataset,
     state: state
   });
   return explorer;
-}
-
-my.QueryEditor = Backbone.View.extend({
-  className: 'recline-query-editor', 
-  template: ' \
-    <form action="" method="GET" class="form-inline"> \
-      <div class="input-prepend text-query"> \
-        <span class="add-on"><i class="icon-search"></i></span> \
-        <input type="text" name="q" value="{{q}}" class="span2" placeholder="Search data ..." class="search-query" /> \
-      </div> \
-      <div class="pagination"> \
-        <ul> \
-          <li class="prev action-pagination-update"><a href="">&laquo;</a></li> \
-          <li class="active"><a><input name="from" type="text" value="{{from}}" /> &ndash; <input name="to" type="text" value="{{to}}" /> </a></li> \
-          <li class="next action-pagination-update"><a href="">&raquo;</a></li> \
-        </ul> \
-      </div> \
-      <button type="submit" class="btn">Go &raquo;</button> \
-    </form> \
-  ',
-
-  events: {
-    'submit form': 'onFormSubmit',
-    'click .action-pagination-update': 'onPaginationUpdate'
-  },
-
-  initialize: function() {
-    _.bindAll(this, 'render');
-    this.el = $(this.el);
-    this.model.bind('change', this.render);
-    this.render();
-  },
-  onFormSubmit: function(e) {
-    e.preventDefault();
-    var query = this.el.find('.text-query input').val();
-    var newFrom = parseInt(this.el.find('input[name="from"]').val());
-    var newSize = parseInt(this.el.find('input[name="to"]').val()) - newFrom;
-    this.model.set({size: newSize, from: newFrom, q: query});
-  },
-  onPaginationUpdate: function(e) {
-    e.preventDefault();
-    var $el = $(e.target);
-    var newFrom = 0;
-    if ($el.parent().hasClass('prev')) {
-      newFrom = this.model.get('from') - Math.max(0, this.model.get('size'));
-    } else {
-      newFrom = this.model.get('from') + this.model.get('size');
-    }
-    this.model.set({from: newFrom});
-  },
-  render: function() {
-    var tmplData = this.model.toJSON();
-    tmplData.to = this.model.get('from') + this.model.get('size');
-    var templated = Mustache.render(this.template, tmplData);
-    this.el.html(templated);
-  }
-});
-
-my.FilterEditor = Backbone.View.extend({
-  className: 'recline-filter-editor well', 
-  template: ' \
-    <a class="close js-hide" href="#">&times;</a> \
-    <div class="row filters"> \
-      <div class="span1"> \
-        <h3>Filters</h3> \
-      </div> \
-      <div class="span11"> \
-        <form class="form-horizontal"> \
-          <div class="row"> \
-            <div class="span6"> \
-              {{#termFilters}} \
-              <div class="control-group filter-term filter" data-filter-id={{id}}> \
-                <label class="control-label" for="">{{label}}</label> \
-                <div class="controls"> \
-                  <div class="input-append"> \
-                    <input type="text" value="{{value}}" name="{{fieldId}}" class="span4" data-filter-field="{{fieldId}}" data-filter-id="{{id}}" data-filter-type="term" /> \
-                    <a class="btn js-remove-filter"><i class="icon-remove"></i></a> \
-                  </div> \
-                </div> \
-              </div> \
-              {{/termFilters}} \
-            </div> \
-          <div class="span4"> \
-            <p>To add a filter use the column menu in the grid view.</p> \
-            <button type="submit" class="btn">Update</button> \
-          </div> \
-        </form> \
-      </div> \
-    </div> \
-  ',
-  events: {
-    'click .js-hide': 'onHide',
-    'click .js-remove-filter': 'onRemoveFilter',
-    'submit form': 'onTermFiltersUpdate'
-  },
-  initialize: function() {
-    this.el = $(this.el);
-    _.bindAll(this, 'render');
-    this.model.bind('change', this.render);
-    this.model.bind('change:filters:new-blank', this.render);
-    this.render();
-  },
-  render: function() {
-    var tmplData = $.extend(true, {}, this.model.toJSON());

we will use idx in list as there id ...

    tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
-      filter.id = idx;
-      return filter;
-    });
-    tmplData.termFilters = _.filter(tmplData.filters, function(filter) {
-      return filter.term !== undefined;
-    });
-    tmplData.termFilters = _.map(tmplData.termFilters, function(filter) {
-      var fieldId = _.keys(filter.term)[0];
-      return {
-        id: filter.id,
-        fieldId: fieldId,
-        label: fieldId,
-        value: filter.term[fieldId]
-      };
-    });
-    var out = Mustache.render(this.template, tmplData);
-    this.el.html(out);

are there actually any facets to show?

    if (this.model.get('filters').length > 0) {
-      this.el.show();
-    } else {
-      this.el.hide();
-    }
-  },
-  onHide: function(e) {
-    e.preventDefault();
-    this.el.hide();
-  },
-  onRemoveFilter: function(e) {
-    e.preventDefault();
-    var $target = $(e.target);
-    var filterId = $target.closest('.filter').attr('data-filter-id');
-    this.model.removeFilter(filterId);
-  },
-  onTermFiltersUpdate: function(e) {
-   var self = this;
-    e.preventDefault();
-    var filters = self.model.get('filters');
-    var $form = $(e.target);
-    _.each($form.find('input'), function(input) {
-      var $input = $(input);
-      var filterIndex = parseInt($input.attr('data-filter-id'));
-      var value = $input.val();
-      var fieldId = $input.attr('data-filter-field');
-      filters[filterIndex].term[fieldId] = value;
-    });
-    self.model.set({filters: filters});
-    self.model.trigger('change');
-  }
-});
-
-my.FacetViewer = Backbone.View.extend({
-  className: 'recline-facet-viewer well', 
-  template: ' \
-    <a class="close js-hide" href="#">&times;</a> \
-    <div class="facets row"> \
-      <div class="span1"> \
-        <h3>Facets</h3> \
-      </div> \
-      {{#facets}} \
-      <div class="facet-summary span2 dropdown" data-facet="{{id}}"> \
-        <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="icon-chevron-down"></i> {{id}} {{label}}</a> \
-        <ul class="facet-items dropdown-menu"> \
-        {{#terms}} \
-          <li><a class="facet-choice js-facet-filter" data-value="{{term}}">{{term}} ({{count}})</a></li> \
-        {{/terms}} \
-        {{#entries}} \
-          <li><a class="facet-choice js-facet-filter" data-value="{{time}}">{{term}} ({{count}})</a></li> \
-        {{/entries}} \
-        </ul> \
-      </div> \
-      {{/facets}} \
-    </div> \
-  ',
-
-  events: {
-    'click .js-hide': 'onHide',
-    'click .js-facet-filter': 'onFacetFilter'
-  },
-  initialize: function(model) {
-    _.bindAll(this, 'render');
-    this.el = $(this.el);
-    this.model.facets.bind('all', this.render);
-    this.model.fields.bind('all', this.render);
-    this.render();
-  },
-  render: function() {
-    var tmplData = {
-      facets: this.model.facets.toJSON(),
-      fields: this.model.fields.toJSON()
+}

Miscellaneous Utilities

var urlPathRegex = /^([^?]+)(\?.*)?/;

Parse the Hash section of a URL into path and query string

my.parseHashUrl = function(hashUrl) {
+  var parsed = urlPathRegex.exec(hashUrl);
+  if (parsed === null) {
+    return {};
+  } else {
+    return {
+      path: parsed[1],
+      query: parsed[2] || ''
     };
-    tmplData.facets = _.map(tmplData.facets, function(facet) {
-      if (facet._type === 'date_histogram') {
-        facet.entries = _.map(facet.entries, function(entry) {
-          entry.term = new Date(entry.time).toDateString();
-          return entry;
-        });
-      }
-      return facet;
-    });
-    var templated = Mustache.render(this.template, tmplData);
-    this.el.html(templated);

are there actually any facets to show?

    if (this.model.facets.length > 0) {
-      this.el.show();
-    } else {
-      this.el.hide();
-    }
-  },
-  onHide: function(e) {
-    e.preventDefault();
-    this.el.hide();
-  },
-  onFacetFilter: function(e) {
-    var $target= $(e.target);
-    var fieldId = $target.closest('.facet-summary').attr('data-facet');
-    var value = $target.attr('data-value');
-    this.model.queryState.addTermFilter(fieldId, value);
   }
-});
+};

Parse a URL query string (?xyz=abc...) into a dictionary.

my.parseQueryString = function(q) {
+  if (!q) {
+    return {};
+  }
+  var urlParams = {},
+    e, d = function (s) {
+      return unescape(s.replace(/\+/g, " "));
+    },
+    r = /([^&=]+)=?([^&]*)/g;
 
+  if (q && q.length && q[0] === '?') {
+    q = q.slice(1);
+  }
+  while (e = r.exec(q)) {

TODO: have values be array as query string allow repetition of keys

    urlParams[d(e[1])] = d(e[2]);
+  }
+  return urlParams;
+};

Parse the query string out of the URL hash

my.parseHashQueryString = function() {
+  q = my.parseHashUrl(window.location.hash).query;
+  return my.parseQueryString(q);
+};

Compse a Query String

my.composeQueryString = function(queryParams) {
+  var queryString = '?';
+  var items = [];
+  $.each(queryParams, function(key, value) {
+    if (typeof(value) === 'object') {
+      value = JSON.stringify(value);
+    }
+    items.push(key + '=' + encodeURIComponent(value));
+  });
+  queryString += items.join('&');
+  return queryString;
+};
+
+my.getNewHashForQueryString = function(queryParams) {
+  var queryPart = my.composeQueryString(queryParams);
+  if (window.location.hash) {

slice(1) to remove # at start

    return window.location.hash.split('?')[0].slice(1) + queryPart;
+  } else {
+    return queryPart;
+  }
+};
+
+my.setHashQueryString = function(queryParams) {
+  window.location.hash = my.getNewHashForQueryString(queryParams);
+};
 
 })(jQuery, recline.View);
 
diff --git a/make b/make
index bbd3430a..c2b75c17 100755
--- a/make
+++ b/make
@@ -20,7 +20,7 @@ def docs():
       shutil.rmtree('/tmp/recline-docs')
     os.makedirs('/tmp/recline-docs')
     files = '%s/src/*.js' % os.getcwd()
-    dest = '%s/docs/source' % os.getcwd()
+    dest = '%s/docs/src' % os.getcwd()
     os.system('cd /tmp/recline-docs && %s %s && mv docs/* %s' % (docco_executable,files, dest))
     print("** Docs built ok")
 

From cc17b54679a81c8acef6820cb240ea384a534967 Mon Sep 17 00:00:00 2001
From: Rufus Pollock 
Date: Tue, 26 Jun 2012 20:52:11 +0100
Subject: [PATCH 09/14] [docs][xs]: correct links to source docs.

---
 docs/index.html | 19 +++++++++----------
 1 file changed, 9 insertions(+), 10 deletions(-)

diff --git a/docs/index.html b/docs/index.html
index f5128c6c..caadbff8 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -115,21 +115,20 @@ root: ../
   
   
 
From 3d944814aef01c726c2cf86c984ae41e05617317 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Tue, 26 Jun 2012 21:05:03 +0100 Subject: [PATCH 10/14] [build][xs]: build latest version of recline. --- dist/recline.js | 2290 +++++++++++++++++++++-------------------------- make | 2 +- 2 files changed, 998 insertions(+), 1294 deletions(-) diff --git a/dist/recline.js b/dist/recline.js index 4fb96e26..31717e0a 100644 --- a/dist/recline.js +++ b/dist/recline.js @@ -1,3 +1,803 @@ +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; +this.recline.Backend.CSV = this.recline.Backend.CSV || {}; + +(function(my) { + // ## fetch + // + // 3 options + // + // 1. CSV local fileobject -> HTML5 file object + CSV parser + // 2. Already have CSV string (in data) attribute -> CSV parser + // 2. online CSV file that is ajax-able -> ajax + csv parser + // + // All options generates similar data and give a memory store outcome + my.fetch = function(dataset) { + var dfd = $.Deferred(); + if (dataset.file) { + var reader = new FileReader(); + var encoding = dataset.encoding || 'UTF-8'; + reader.onload = function(e) { + var rows = my.parseCSV(e.target.result, dataset); + dfd.resolve({ + records: rows, + metadata: { + filename: dataset.file.name + }, + useMemoryStore: true + }); + }; + reader.onerror = function (e) { + alert('Failed to load file. Code: ' + e.target.error.code); + }; + reader.readAsText(dataset.file, encoding); + } else if (dataset.data) { + var rows = my.parseCSV(dataset.data, dataset); + dfd.resolve({ + records: rows, + useMemoryStore: true + }); + } else if (dataset.url) { + $.get(dataset.url).done(function(data) { + var rows = my.parseCSV(dataset.data, dataset); + dfd.resolve({ + records: rows, + useMemoryStore: true + }); + }); + } + return dfd.promise(); + }; + + // Converts a Comma Separated Values string into an array of arrays. + // Each line in the CSV becomes an array. + // + // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats. + // + // @return The CSV parsed as an array + // @type Array + // + // @param {String} s The string to convert + // @param {Object} options Options for loading CSV including + // @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported + // @param {String} [separator=','] Separator for CSV file + // Heavily based on uselesscode's JS CSV parser (MIT Licensed): + // http://www.uselesscode.org/javascript/csv/ + my.parseCSV= function(s, options) { + // Get rid of any trailing \n + s = chomp(s); + + var options = options || {}; + var trm = (options.trim === false) ? false : true; + var separator = options.separator || ','; + var delimiter = options.delimiter || '"'; + + var cur = '', // The character we are currently processing. + inQuote = false, + fieldQuoted = false, + field = '', // Buffer for building up the current field + row = [], + out = [], + i, + processField; + + processField = function (field) { + if (fieldQuoted !== true) { + // If field is empty set to null + if (field === '') { + field = null; + // If the field was not quoted and we are trimming fields, trim it + } else if (trm === true) { + field = trim(field); + } + + // Convert unquoted numbers to their appropriate types + if (rxIsInt.test(field)) { + field = parseInt(field, 10); + } else if (rxIsFloat.test(field)) { + field = parseFloat(field, 10); + } + } + return field; + }; + + for (i = 0; i < s.length; i += 1) { + cur = s.charAt(i); + + // If we are at a EOF or EOR + if (inQuote === false && (cur === separator || cur === "\n")) { + field = processField(field); + // Add the current field to the current row + row.push(field); + // If this is EOR append row to output and flush row + if (cur === "\n") { + out.push(row); + row = []; + } + // Flush the field buffer + field = ''; + fieldQuoted = false; + } else { + // If it's not a delimiter, add it to the field buffer + if (cur !== delimiter) { + field += cur; + } else { + if (!inQuote) { + // We are not in a quote, start a quote + inQuote = true; + fieldQuoted = true; + } else { + // Next char is delimiter, this is an escaped delimiter + if (s.charAt(i + 1) === delimiter) { + field += delimiter; + // Skip the next char + i += 1; + } else { + // It's not escaping, so end quote + inQuote = false; + } + } + } + } + } + + // Add the last field + field = processField(field); + row.push(field); + out.push(row); + + return out; + }; + + var rxIsInt = /^\d+$/, + rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, + // If a string has leading or trailing space, + // contains a comma double quote or a newline + // it needs to be quoted in CSV output + rxNeedsQuoting = /^\s|\s$|,|"|\n/, + trim = (function () { + // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists + if (String.prototype.trim) { + return function (s) { + return s.trim(); + }; + } else { + return function (s) { + return s.replace(/^\s*/, '').replace(/\s*$/, ''); + }; + } + }()); + + function chomp(s) { + if (s.charAt(s.length - 1) !== "\n") { + // Does not end with \n, just return string + return s; + } else { + // Remove the \n + return s.substring(0, s.length - 1); + } + } + + +}(this.recline.Backend.CSV)); +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; +this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; + +(function($, my) { + my.__type__ = 'dataproxy'; + // URL for the dataproxy + my.dataproxy_url = 'http://jsonpdataproxy.appspot.com'; + + // ## load + // + // Load data from a URL via the [DataProxy](http://github.com/okfn/dataproxy). + // + // Returns array of field names and array of arrays for records + my.fetch = function(dataset) { + var data = { + url: dataset.url, + 'max-results': dataset.size || dataset.rows || 1000, + type: dataset.format || '' + }; + var jqxhr = $.ajax({ + url: my.dataproxy_url, + data: data, + dataType: 'jsonp' + }); + var dfd = $.Deferred(); + _wrapInTimeout(jqxhr).done(function(results) { + if (results.error) { + dfd.reject(results.error); + } + + dfd.resolve({ + records: results.data, + fields: results.fields, + useMemoryStore: true + }); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }; + + // ## _wrapInTimeout + // + // Convenience method providing a crude way to catch backend errors on JSONP calls. + // Many of backends use JSONP and so will not get error messages and this is + // a crude way to catch those errors. + var _wrapInTimeout = function(ourFunction) { + var dfd = $.Deferred(); + var timeout = 5000; + var timer = setTimeout(function() { + dfd.reject({ + message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' + }); + }, timeout); + ourFunction.done(function(arguments) { + clearTimeout(timer); + dfd.resolve(arguments); + }) + .fail(function(arguments) { + clearTimeout(timer); + dfd.reject(arguments); + }) + ; + return dfd.promise(); + } + +}(jQuery, this.recline.Backend.DataProxy)); +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; +this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; + +(function($, my) { + my.__type__ = 'elasticsearch'; + + // ## ElasticSearch Wrapper + // + // A simple JS wrapper around an [ElasticSearch](http://www.elasticsearch.org/) endpoints. + // + // @param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running + // on http://localhost:9200 with index twitter and type tweet it would be: + // + //
http://localhost:9200/twitter/tweet
+ // + // @param {Object} options: set of options such as: + // + // * headers - {dict of headers to add to each request} + // * dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests) + my.Wrapper = function(endpoint, options) { + var self = this; + this.endpoint = endpoint; + this.options = _.extend({ + dataType: 'json' + }, + options); + + // ### mapping + // + // Get ES mapping for this type/table + // + // @return promise compatible deferred object. + this.mapping = function() { + var schemaUrl = self.endpoint + '/_mapping'; + var jqxhr = makeRequest({ + url: schemaUrl, + dataType: this.options.dataType + }); + return jqxhr; + }; + + // ### get + // + // Get record corresponding to specified id + // + // @return promise compatible deferred object. + this.get = function(id) { + var base = this.endpoint + '/' + id; + return makeRequest({ + url: base, + dataType: 'json' + }); + }; + + // ### upsert + // + // create / update a record to ElasticSearch backend + // + // @param {Object} doc an object to insert to the index. + // @return deferred supporting promise API + this.upsert = function(doc) { + var data = JSON.stringify(doc); + url = this.endpoint; + if (doc.id) { + url += '/' + doc.id; + } + return makeRequest({ + url: url, + type: 'POST', + data: data, + dataType: 'json' + }); + }; + + // ### delete + // + // Delete a record from the ElasticSearch backend. + // + // @param {Object} id id of object to delete + // @return deferred supporting promise API + this.delete = function(id) { + url = this.endpoint; + url += '/' + id; + return makeRequest({ + url: url, + type: 'DELETE', + dataType: 'json' + }); + }; + + this._normalizeQuery = function(queryObj) { + var self = this; + var queryInfo = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj); + var out = { + constant_score: { + query: {} + } + }; + if (!queryInfo.q) { + out.constant_score.query = { + match_all: {} + }; + } else { + out.constant_score.query = { + query_string: { + query: queryInfo.q + } + }; + } + if (queryInfo.filters && queryInfo.filters.length) { + out.constant_score.filter = { + and: [] + }; + _.each(queryInfo.filters, function(filter) { + out.constant_score.filter.and.push(self._convertFilter(filter)); + }); + } + return out; + }, + + this._convertFilter = function(filter) { + var out = {}; + out[filter.type] = {} + if (filter.type === 'term') { + out.term[filter.field] = filter.term.toLowerCase(); + } else if (filter.type === 'geo_distance') { + out.geo_distance[filter.field] = filter.point; + out.geo_distance.distance = filter.distance; + out.geo_distance.unit = filter.unit; + } + return out; + }, + + // ### query + // + // @return deferred supporting promise API + this.query = function(queryObj) { + var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj); + var queryNormalized = this._normalizeQuery(queryObj); + delete esQuery.q; + delete esQuery.filters; + esQuery.query = queryNormalized; + var data = {source: JSON.stringify(esQuery)}; + var url = this.endpoint + '/_search'; + var jqxhr = makeRequest({ + url: url, + data: data, + dataType: this.options.dataType + }); + return jqxhr; + } + }; + + + // ## Recline Connectors + // + // Requires URL of ElasticSearch endpoint to be specified on the dataset + // via the url attribute. + + // ES options which are passed through to `options` on Wrapper (see Wrapper for details) + my.esOptions = {}; + + // ### fetch + my.fetch = function(dataset) { + var es = new my.Wrapper(dataset.url, my.esOptions); + var dfd = $.Deferred(); + es.mapping().done(function(schema) { + // only one top level key in ES = the type so we can ignore it + var key = _.keys(schema)[0]; + var fieldData = _.map(schema[key].properties, function(dict, fieldName) { + dict.id = fieldName; + return dict; + }); + dfd.resolve({ + fields: fieldData + }); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + return dfd.promise(); + }; + + // ### save + my.save = function(changes, dataset) { + var es = new my.Wrapper(dataset.url, my.esOptions); + if (changes.creates.length + changes.updates.length + changes.deletes.length > 1) { + var dfd = $.Deferred(); + msg = 'Saving more than one item at a time not yet supported'; + alert(msg); + dfd.reject(msg); + return dfd.promise(); + } + if (changes.creates.length > 0) { + return es.upsert(changes.creates[0]); + } + else if (changes.updates.length >0) { + return es.upsert(changes.updates[0]); + } else if (changes.deletes.length > 0) { + return es.delete(changes.deletes[0].id); + } + }; + + // ### query + my.query = function(queryObj, dataset) { + var dfd = $.Deferred(); + var es = new my.Wrapper(dataset.url, my.esOptions); + var jqxhr = es.query(queryObj); + jqxhr.done(function(results) { + var out = { + total: results.hits.total, + }; + out.hits = _.map(results.hits.hits, function(hit) { + if (!('id' in hit._source) && hit._id) { + hit._source.id = hit._id; + } + return hit._source; + }); + if (results.facets) { + out.facets = results.facets; + } + dfd.resolve(out); + }).fail(function(errorObj) { + var out = { + title: 'Failed: ' + errorObj.status + ' code', + message: errorObj.responseText + }; + dfd.reject(out); + }); + return dfd.promise(); + }; + + +// ### makeRequest +// +// Just $.ajax but in any headers in the 'headers' attribute of this +// Backend instance. Example: +// +//
+// var jqxhr = this._makeRequest({
+//   url: the-url
+// });
+// 
+var makeRequest = function(data, headers) { + var extras = {}; + if (headers) { + extras = { + beforeSend: function(req) { + _.each(headers, function(value, key) { + req.setRequestHeader(key, value); + }); + } + }; + } + var data = _.extend(extras, data); + return $.ajax(data); +}; + +}(jQuery, this.recline.Backend.ElasticSearch)); + +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; +this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; + +(function($, my) { + my.__type__ = 'gdocs'; + + // ## Google spreadsheet backend + // + // Fetch data from a Google Docs spreadsheet. + // + // Dataset must have a url attribute pointing to the Gdocs or its JSON feed e.g. + //
+  // var dataset = new recline.Model.Dataset({
+  //     url: 'https://docs.google.com/spreadsheet/ccc?key=0Aon3JiuouxLUdGlQVDJnbjZRSU1tUUJWOUZXRG53VkE#gid=0'
+  //   },
+  //   'gdocs'
+  // );
+  //
+  // var dataset = new recline.Model.Dataset({
+  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
+  //   },
+  //   'gdocs'
+  // );
+  // 
+ // + // @return object with two attributes + // + // * fields: array of Field objects + // * records: array of objects for each row + my.fetch = function(dataset) { + var dfd = $.Deferred(); + var url = my.getSpreadsheetAPIUrl(dataset.url); + $.getJSON(url, function(d) { + result = my.parseData(d); + var fields = _.map(result.fields, function(fieldId) { + return {id: fieldId}; + }); + dfd.resolve({ + records: result.records, + fields: fields, + useMemoryStore: true + }); + }); + return dfd.promise(); + }; + + // ## parseData + // + // Parse data from Google Docs API into a reasonable form + // + // :options: (optional) optional argument dictionary: + // columnsToUse: list of columns to use (specified by field names) + // colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). + // :return: tabular data object (hash with keys: field and data). + // + // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. + my.parseData = function(gdocsSpreadsheet) { + var options = {}; + if (arguments.length > 1) { + options = arguments[1]; + } + var results = { + fields: [], + records: [] + }; + // default is no special info on type of columns + var colTypes = {}; + if (options.colTypes) { + colTypes = options.colTypes; + } + if (gdocsSpreadsheet.feed.entry.length > 0) { + for (var k in gdocsSpreadsheet.feed.entry[0]) { + if (k.substr(0, 3) == 'gsx') { + var col = k.substr(4); + results.fields.push(col); + } + } + } + + // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) + var rep = /^([\d\.\-]+)\%$/; + results.records = _.map(gdocsSpreadsheet.feed.entry, function(entry) { + var row = {}; + _.each(results.fields, function(col) { + var _keyname = 'gsx$' + col; + var value = entry[_keyname]['$t']; + // if labelled as % and value contains %, convert + if (colTypes[col] == 'percent') { + if (rep.test(value)) { + var value2 = rep.exec(value); + var value3 = parseFloat(value2); + value = value3 / 100; + } + } + row[col] = value; + }); + return row; + }); + return results; + }; + + // Convenience function to get GDocs JSON API Url from standard URL + my.getSpreadsheetAPIUrl = function(url) { + if (url.indexOf('feeds/list') != -1) { + return url; + } else { + // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0 + var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/; + var matches = url.match(regex); + if (matches) { + var key = matches[1]; + var worksheet = 1; + var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json'; + return out; + } else { + alert('Failed to extract gdocs key from ' + url); + } + } + }; +}(jQuery, this.recline.Backend.GDocs)); + +this.recline = this.recline || {}; +this.recline.Backend = this.recline.Backend || {}; +this.recline.Backend.Memory = this.recline.Backend.Memory || {}; + +(function($, my) { + my.__type__ = 'memory'; + + // ## Data Wrapper + // + // Turn a simple array of JS objects into a mini data-store with + // functionality like querying, faceting, updating (by ID) and deleting (by + // ID). + // + // @param data list of hashes for each record/row in the data ({key: + // value, key: value}) + // @param fields (optional) list of field hashes (each hash defining a field + // as per recline.Model.Field). If fields not specified they will be taken + // from the data. + my.Store = function(data, fields) { + var self = this; + this.data = data; + if (fields) { + this.fields = fields; + } else { + if (data) { + this.fields = _.map(data[0], function(value, key) { + return {id: key}; + }); + } + } + + this.update = function(doc) { + _.each(self.data, function(internalDoc, idx) { + if(doc.id === internalDoc.id) { + self.data[idx] = doc; + } + }); + }; + + this.delete = function(doc) { + var newdocs = _.reject(self.data, function(internalDoc) { + return (doc.id === internalDoc.id); + }); + this.data = newdocs; + }; + + this.save = function(changes, dataset) { + var self = this; + var dfd = $.Deferred(); + // TODO _.each(changes.creates) { ... } + _.each(changes.updates, function(record) { + self.update(record); + }); + _.each(changes.deletes, function(record) { + self.delete(record); + }); + dfd.resolve(); + return dfd.promise(); + }, + + this.query = function(queryObj) { + var dfd = $.Deferred(); + var numRows = queryObj.size || this.data.length; + var start = queryObj.from || 0; + var results = this.data; + results = this._applyFilters(results, queryObj); + results = this._applyFreeTextQuery(results, queryObj); + // not complete sorting! + _.each(queryObj.sort, function(sortObj) { + var fieldName = _.keys(sortObj)[0]; + results = _.sortBy(results, function(doc) { + var _out = doc[fieldName]; + return _out; + }); + if (sortObj[fieldName].order == 'desc') { + results.reverse(); + } + }); + var facets = this.computeFacets(results, queryObj); + var out = { + total: results.length, + hits: results.slice(start, start+numRows), + facets: facets + }; + dfd.resolve(out); + return dfd.promise(); + }; + + // in place filtering + this._applyFilters = function(results, queryObj) { + _.each(queryObj.filters, function(filter) { + // if a term filter ... + if (filter.type === 'term') { + results = _.filter(results, function(doc) { + return (doc[filter.field] == filter.term); + }); + } + }); + return results; + }; + + // we OR across fields but AND across terms in query string + this._applyFreeTextQuery = function(results, queryObj) { + if (queryObj.q) { + var terms = queryObj.q.split(' '); + results = _.filter(results, function(rawdoc) { + var matches = true; + _.each(terms, function(term) { + var foundmatch = false; + _.each(self.fields, function(field) { + var value = rawdoc[field.id]; + if (value !== null) { value = value.toString(); } + // TODO regexes? + foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase()); + // TODO: early out (once we are true should break to spare unnecessary testing) + // if (foundmatch) return true; + }); + matches = matches && foundmatch; + // TODO: early out (once false should break to spare unnecessary testing) + // if (!matches) return false; + }); + return matches; + }); + } + return results; + }; + + this.computeFacets = function(records, queryObj) { + var facetResults = {}; + if (!queryObj.facets) { + return facetResults; + } + _.each(queryObj.facets, function(query, facetId) { + // TODO: remove dependency on recline.Model + facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); + facetResults[facetId].termsall = {}; + }); + // faceting + _.each(records, function(doc) { + _.each(queryObj.facets, function(query, facetId) { + var fieldId = query.terms.field; + var val = doc[fieldId]; + var tmp = facetResults[facetId]; + if (val) { + tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1; + } else { + tmp.missing = tmp.missing + 1; + } + }); + }); + _.each(queryObj.facets, function(query, facetId) { + var tmp = facetResults[facetId]; + var terms = _.map(tmp.termsall, function(count, term) { + return { term: term, count: count }; + }); + tmp.terms = _.sortBy(terms, function(item) { + // want descending order + return -item.count; + }); + tmp.terms = tmp.terms.slice(0, 10); + }); + return facetResults; + }; + }; + +}(jQuery, this.recline.Backend.Memory)); // adapted from https://github.com/harthur/costco. heather rules var costco = function() { @@ -72,56 +872,149 @@ this.recline.Model = this.recline.Model || {}; (function($, my) { -// ## A Dataset model -// -// A model has the following (non-Backbone) attributes: -// -// @property {FieldList} fields: (aka columns) is a `FieldList` listing all the -// fields on this Dataset (this can be set explicitly, or, will be set by -// Dataset.fetch() or Dataset.query() -// -// @property {RecordList} currentRecords: a `RecordList` containing the -// Records we have currently loaded for viewing (updated by calling query -// method) -// -// @property {number} docCount: total number of records in this dataset -// -// @property {Backend} backend: the Backend (instance) for this Dataset. -// -// @property {Query} queryState: `Query` object which stores current -// queryState. queryState may be edited by other components (e.g. a query -// editor view) changes will trigger a Dataset query. -// -// @property {FacetList} facets: FacetList object containing all current -// Facets. +// ## Dataset my.Dataset = Backbone.Model.extend({ __type__: 'Dataset', // ### initialize - // - // Sets up instance properties (see above) - // - // @param {Object} model: standard set of model attributes passed to Backbone models - // - // @param {Object or String} backend: Backend instance (see - // `recline.Backend.Base`) or a string specifying that instance. The - // string specifying may be a full class path e.g. - // 'recline.Backend.ElasticSearch' or a simple name e.g. - // 'elasticsearch' or 'ElasticSearch' (in this case must be a Backend in - // recline.Backend module) - initialize: function(model, backend) { + initialize: function() { _.bindAll(this, 'query'); - this.backend = backend; - if (typeof(backend) === 'string') { - this.backend = this._backendFromString(backend); + this.backend = null; + if (this.get('backend')) { + this.backend = this._backendFromString(this.get('backend')); + } else { // try to guess backend ... + if (this.get('records')) { + this.backend = recline.Backend.Memory; + } } this.fields = new my.FieldList(); this.currentRecords = new my.RecordList(); + this._changes = { + deletes: [], + updates: [], + creates: [] + }; this.facets = new my.FacetList(); this.docCount = null; this.queryState = new my.Query(); this.queryState.bind('change', this.query); this.queryState.bind('facet:add', this.query); + // store is what we query and save against + // store will either be the backend or be a memory store if Backend fetch + // tells us to use memory store + this._store = this.backend; + if (this.backend == recline.Backend.Memory) { + this.fetch(); + } + }, + + // ### fetch + // + // Retrieve dataset and (some) records from the backend. + fetch: function() { + var self = this; + var dfd = $.Deferred(); + + if (this.backend !== recline.Backend.Memory) { + this.backend.fetch(this.toJSON()) + .done(handleResults) + .fail(function(arguments) { + dfd.reject(arguments); + }); + } else { + // special case where we have been given data directly + handleResults({ + records: this.get('records'), + fields: this.get('fields'), + useMemoryStore: true + }); + } + + function handleResults(results) { + var out = self._normalizeRecordsAndFields(results.records, results.fields); + if (results.useMemoryStore) { + self._store = new recline.Backend.Memory.Store(out.records, out.fields); + } + + self.set(results.metadata); + self.fields.reset(out.fields); + self.query() + .done(function() { + dfd.resolve(self); + }) + .fail(function(arguments) { + dfd.reject(arguments); + }); + } + + return dfd.promise(); + }, + + // ### _normalizeRecordsAndFields + // + // Get a proper set of fields and records from incoming set of fields and records either of which may be null or arrays or objects + // + // e.g. fields = ['a', 'b', 'c'] and records = [ [1,2,3] ] => + // fields = [ {id: a}, {id: b}, {id: c}], records = [ {a: 1}, {b: 2}, {c: 3}] + _normalizeRecordsAndFields: function(records, fields) { + // if no fields get them from records + if (!fields && records && records.length > 0) { + // records is array then fields is first row of records ... + if (records[0] instanceof Array) { + fields = records[0]; + records = records.slice(1); + } else { + fields = _.map(_.keys(records[0]), function(key) { + return {id: key}; + }); + } + } + + // fields is an array of strings (i.e. list of field headings/ids) + if (fields && fields.length > 0 && typeof fields[0] === 'string') { + // Rename duplicate fieldIds as each field name needs to be + // unique. + var seen = {}; + fields = _.map(fields, function(field, index) { + // cannot use trim as not supported by IE7 + var fieldId = field.replace(/^\s+|\s+$/g, ''); + if (fieldId === '') { + fieldId = '_noname_'; + field = fieldId; + } + while (fieldId in seen) { + seen[field] += 1; + fieldId = field + seen[field]; + } + if (!(field in seen)) { + seen[field] = 0; + } + // TODO: decide whether to keep original name as label ... + // return { id: fieldId, label: field || fieldId } + return { id: fieldId }; + }); + } + // records is provided as arrays so need to zip together with fields + // NB: this requires you to have fields to match arrays + if (records && records.length > 0 && records[0] instanceof Array) { + records = _.map(records, function(doc) { + var tmp = {}; + _.each(fields, function(field, idx) { + tmp[field.id] = doc[idx]; + }); + return tmp; + }); + } + return { + fields: fields, + records: records + }; + }, + + save: function() { + var self = this; + // TODO: need to reset the changes ... + return this._store.save(this._changes, this.toJSON()); }, // ### query @@ -135,41 +1028,48 @@ my.Dataset = Backbone.Model.extend({ // also returned. query: function(queryObj) { var self = this; - this.trigger('query:start'); - var actualQuery = self._prepareQuery(queryObj); var dfd = $.Deferred(); - this.backend.query(this, actualQuery).done(function(queryResult) { - self.docCount = queryResult.total; - var docs = _.map(queryResult.hits, function(hit) { - var _doc = new my.Record(hit._source); - _doc.backend = self.backend; - _doc.dataset = self; - return _doc; + this.trigger('query:start'); + + if (queryObj) { + this.queryState.set(queryObj); + } + var actualQuery = this.queryState.toJSON(); + + this._store.query(actualQuery, this.toJSON()) + .done(function(queryResult) { + self._handleQueryResult(queryResult); + self.trigger('query:done'); + dfd.resolve(self.currentRecords); + }) + .fail(function(arguments) { + self.trigger('query:fail', arguments); + dfd.reject(arguments); }); - self.currentRecords.reset(docs); - if (queryResult.facets) { - var facets = _.map(queryResult.facets, function(facetResult, facetId) { - facetResult.id = facetId; - return new my.Facet(facetResult); - }); - self.facets.reset(facets); - } - self.trigger('query:done'); - dfd.resolve(self.currentRecords); - }) - .fail(function(arguments) { - self.trigger('query:fail', arguments); - dfd.reject(arguments); - }); return dfd.promise(); }, - _prepareQuery: function(newQueryObj) { - if (newQueryObj) { - this.queryState.set(newQueryObj); + _handleQueryResult: function(queryResult) { + var self = this; + self.docCount = queryResult.total; + var docs = _.map(queryResult.hits, function(hit) { + var _doc = new my.Record(hit); + _doc.bind('change', function(doc) { + self._changes.updates.push(doc.toJSON()); + }); + _doc.bind('destroy', function(doc) { + self._changes.deletes.push(doc.toJSON()); + }); + return _doc; + }); + self.currentRecords.reset(docs); + if (queryResult.facets) { + var facets = _.map(queryResult.facets, function(facetResult, facetId) { + facetResult.id = facetId; + return new my.Facet(facetResult); + }); + self.facets.reset(facets); } - var out = this.queryState.toJSON(); - return out; }, toTemplateJSON: function() { @@ -190,7 +1090,7 @@ my.Dataset = Backbone.Model.extend({ query.addFacet(field.id); }); var dfd = $.Deferred(); - this.backend.query(this, query.toJSON()).done(function(queryResult) { + this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) { if (queryResult.facets) { _.each(queryResult.facets, function(facetResult, facetId) { facetResult.id = facetId; @@ -218,7 +1118,7 @@ my.Dataset = Backbone.Model.extend({ current = current[parts[ii]]; } if (current) { - return new current(); + return current; } // alternatively we just had a simple string @@ -226,7 +1126,7 @@ my.Dataset = Backbone.Model.extend({ if (recline && recline.Backend) { _.each(_.keys(recline.Backend), function(name) { if (name.toLowerCase() === backendString.toLowerCase()) { - backend = new recline.Backend[name].Backbone(); + backend = recline.Backend[name]; } }); } @@ -252,20 +1152,16 @@ my.Dataset.restore = function(state) { var dataset = null; // hack-y - restoring a memory dataset does not mean much ... if (state.backend === 'memory') { - dataset = recline.Backend.Memory.createDataset( - [{stub: 'this is a stub dataset because we do not restore memory datasets'}], - [], - state.dataset // metadata - ); + var datasetInfo = { + records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}] + }; } else { var datasetInfo = { - url: state.url + url: state.url, + backend: state.backend }; - dataset = new recline.Model.Dataset( - datasetInfo, - state.backend - ); } + dataset = new recline.Model.Dataset(datasetInfo); return dataset; }; @@ -310,7 +1206,15 @@ my.Record = Backbone.Model.extend({ } } return html; - } + }, + + // Override Backbone save, fetch and destroy so they do nothing + // Instead, Dataset object that created this Record should take care of + // handling these changes (discovery will occur via event notifications) + // WARNING: these will not persist *unless* you call save on Dataset + fetch: function() {}, + save: function() {}, + destroy: function() { this.trigger('destroy', this); } }); // ## A Backbone collection of Records @@ -320,42 +1224,6 @@ my.RecordList = Backbone.Collection.extend({ }); // ## A Field (aka Column) on a Dataset -// -// Following (Backbone) attributes as standard: -// -// * id: a unique identifer for this field- usually this should match the key in the records hash -// * label: (optional: defaults to id) the visible label used for this field -// * type: (optional: defaults to string) the type of the data in this field. Should be a string as per type names defined by ElasticSearch - see Types list on -// * format: (optional) used to indicate how the data should be formatted. For example: -// * type=date, format=yyyy-mm-dd -// * type=float, format=percentage -// * type=string, format=markdown (render as markdown if Showdown available) -// * is_derived: (default: false) attribute indicating this field has no backend data but is just derived from other fields (see below). -// -// Following additional instance properties: -// -// @property {Function} renderer: a function to render the data for this field. -// Signature: function(value, field, record) where value is the value of this -// cell, field is corresponding field object and record is the record -// object (as simple JS object). Note that implementing functions can ignore arguments (e.g. -// function(value) would be a valid formatter function). -// -// @property {Function} deriver: a function to derive/compute the value of data -// in this field as a function of this field's value (if any) and the current -// record, its signature and behaviour is the same as for renderer. Use of -// this function allows you to define an entirely new value for data in this -// field. This provides support for a) 'derived/computed' fields: i.e. fields -// whose data are functions of the data in other fields b) transforming the -// value of this field prior to rendering. -// -// #### Default renderers -// -// * string -// * no format provided: pass through but convert http:// to hyperlinks -// * format = plain: do no processing on the source text -// * format = markdown: process as markdown (if Showdown library available) -// * float -// * format = percentage: format as a percentage my.Field = Backbone.Model.extend({ // ### defaults - define default values defaults: { @@ -426,54 +1294,6 @@ my.FieldList = Backbone.Collection.extend({ }); // ## Query -// -// Query instances encapsulate a query to the backend (see query method on backend). Useful both -// for creating queries and for storing and manipulating query state - -// e.g. from a query editor). -// -// **Query Structure and format** -// -// Query structure should follow that of [ElasticSearch query -// language](http://www.elasticsearch.org/guide/reference/api/search/). -// -// **NB: It is up to specific backends how to implement and support this query -// structure. Different backends might choose to implement things differently -// or not support certain features. Please check your backend for details.** -// -// Query object has the following key attributes: -// -// * size (=limit): number of results to return -// * from (=offset): offset into result set - http://www.elasticsearch.org/guide/reference/api/search/from-size.html -// * sort: sort order - -// * query: Query in ES Query DSL -// * filter: See filters and Filtered Query -// * fields: set of fields to return - http://www.elasticsearch.org/guide/reference/api/search/fields.html -// * facets: specification of facets - see http://www.elasticsearch.org/guide/reference/api/search/facets/ -// -// Additions: -// -// * q: either straight text or a hash will map directly onto a [query_string -// query](http://www.elasticsearch.org/guide/reference/query-dsl/query-string-query.html) -// in backend -// -// * Of course this can be re-interpreted by different backends. E.g. some -// may just pass this straight through e.g. for an SQL backend this could be -// the full SQL query -// -// * filters: array of ElasticSearch filters. These will be and-ed together for -// execution. -// -// **Examples** -// -//
-// {
-//    q: 'quick brown fox',
-//    filters: [
-//      { term: { 'owner': 'jones' } }
-//    ]
-// }
-// 
my.Query = Backbone.Model.extend({ defaults: function() { return { @@ -492,7 +1312,7 @@ my.Query = Backbone.Model.extend({ }, geo_distance: { distance: 10, - distance_unit: 'km', + unit: 'km', point: { lon: 0, lat: 0 @@ -517,41 +1337,6 @@ my.Query = Backbone.Model.extend({ }, updateFilter: function(index, value) { }, - // #### addTermFilter - // - // Set (update or add) a terms filter to filters - // - // See - addTermFilter: function(fieldId, value) { - var filters = this.get('filters'); - var filter = { term: {} }; - filter.term[fieldId] = value || ''; - filters.push(filter); - this.set({filters: filters}); - // change does not seem to be triggered automatically - if (value) { - this.trigger('change'); - } else { - // adding a new blank filter and do not want to trigger a new query - this.trigger('change:filters:new-blank'); - } - }, - addGeoDistanceFilter: function(field) { - var filters = this.get('filters'); - var filter = { - geo_distance: { - distance: '10km', - } - }; - filter.geo_distance[field] = { - 'lon': 0, - 'lat': 0 - }; - filters.push(filter); - this.set({filters: filters}); - // adding a new blank filter and do not want to trigger a new query - this.trigger('change:filters:new-blank'); - }, // ### removeFilter // // Remove a filter from filters at index filterIndex @@ -593,43 +1378,6 @@ my.Query = Backbone.Model.extend({ // ## A Facet (Result) -// -// Object to store Facet information, that is summary information (e.g. values -// and counts) about a field obtained by some faceting method on the -// backend. -// -// Structure of a facet follows that of Facet results in ElasticSearch, see: -// -// -// Specifically the object structure of a facet looks like (there is one -// addition compared to ElasticSearch: the "id" field which corresponds to the -// key used to specify this facet in the facet query): -// -//
-// {
-//   "id": "id-of-facet",
-//   // type of this facet (terms, range, histogram etc)
-//   "_type" : "terms",
-//   // total number of tokens in the facet
-//   "total": 5,
-//   // @property {number} number of records which have no value for the field
-//   "missing" : 0,
-//   // number of facet values not included in the returned facets
-//   "other": 0,
-//   // term object ({term: , count: ...})
-//   "terms" : [ {
-//       "term" : "foo",
-//       "count" : 2
-//     }, {
-//       "term" : "bar",
-//       "count" : 2
-//     }, {
-//       "term" : "baz",
-//       "count" : 1
-//     }
-//   ]
-// }
-// 
my.Facet = Backbone.Model.extend({ defaults: function() { return { @@ -1919,7 +2667,7 @@ my.MapMenu = Backbone.View.extend({ events: { 'click .editor-update-map': 'onEditorSubmit', 'change .editor-field-type': 'onFieldTypeChange', - 'change #editor-auto-zoom': 'onAutoZoomChange' + 'click #editor-auto-zoom': 'onAutoZoomChange' }, initialize: function(options) { @@ -1943,13 +2691,19 @@ my.MapMenu = Backbone.View.extend({ if (this._geomReady() && this.model.fields.length){ if (this.state.get('geomField')){ this._selectOption('editor-geom-field',this.state.get('geomField')); - $('#editor-field-type-geom').attr('checked','checked').change(); + this.el.find('#editor-field-type-geom').attr('checked','checked').change(); } else{ this._selectOption('editor-lon-field',this.state.get('lonField')); this._selectOption('editor-lat-field',this.state.get('latField')); - $('#editor-field-type-latlon').attr('checked','checked').change(); + this.el.find('#editor-field-type-latlon').attr('checked','checked').change(); } } + if (this.state.get('autoZoom')) { + this.el.find('#editor-auto-zoom').attr('checked', 'checked'); + } + else { + this.el.find('#editor-auto-zoom').removeAttr('checked'); + } return this; }, @@ -2106,7 +2860,7 @@ my.MultiView = Backbone.View.extend({ \ \ @@ -2201,10 +2955,9 @@ my.MultiView = Backbone.View.extend({ // retrieve basic data like fields etc // note this.model and dataset returned are the same + // TODO: set query state ...? + this.model.queryState.set(self.state.get('query'), {silent: true}); this.model.fetch() - .done(function(dataset) { - self.model.query(self.state.get('query')); - }) .fail(function(error) { self.notify({message: error.message, category: 'error', persist: true}); }); @@ -2247,12 +3000,6 @@ my.MultiView = Backbone.View.extend({ }); this.$filterEditor = filterEditor.el; $dataSidebar.append(filterEditor.el); - // are there actually any filters to show? - if (this.model.get('filters') && this.model.get('filters').length > 0) { - this.$filterEditor.show(); - } else { - this.$filterEditor.hide(); - } var fieldsView = new recline.View.Fields({ model: this.model @@ -3596,1046 +4343,3 @@ my.QueryEditor = Backbone.View.extend({ })(jQuery, recline.View); -// # Recline Backends -// -// Backends are connectors to backend data sources and stores -// -// This is just the base module containing a template Base class and convenience methods. -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; - -// ## recline.Backend.Base -// -// Exemplar 'class' for backends showing what a base class would look like. -this.recline.Backend.Base = function() { - // ### __type__ - // - // 'type' of this backend. This should be either the class path for this - // object as a string (e.g. recline.Backend.Memory) or for Backends within - // recline.Backend module it may be their class name. - // - // This value is used as an identifier for this backend when initializing - // backends (see recline.Model.Dataset.initialize). - this.__type__ = 'base'; - - // ### readonly - // - // Class level attribute indicating that this backend is read-only (that - // is, cannot be written to). - this.readonly = true; - - // ### sync - // - // An implementation of Backbone.sync that will be used to override - // Backbone.sync on operations for Datasets and Records which are using this backend. - // - // For read-only implementations you will need only to implement read method - // for Dataset models (and even this can be a null operation). The read method - // should return relevant metadata for the Dataset. We do not require read support - // for Records because they are loaded in bulk by the query method. - // - // For backends supporting write operations you must implement update and delete support for Record objects. - // - // All code paths should return an object conforming to the jquery promise API. - this.sync = function(method, model, options) { - }, - - // ### query - // - // Query the backend for records returning them in bulk. This method will - // be used by the Dataset.query method to search the backend for records, - // retrieving the results in bulk. - // - // @param {recline.model.Dataset} model: Dataset model. - // - // @param {Object} queryObj: object describing a query (usually produced by - // using recline.Model.Query and calling toJSON on it). - // - // The structure of data in the Query object or - // Hash should follow that defined in issue 34. - // (Of course, if you are writing your own backend, and hence - // have control over the interpretation of the query object, you - // can use whatever structure you like). - // - // @returns {Promise} promise API object. The promise resolve method will - // be called on query completion with a QueryResult object. - // - // A QueryResult has the following structure (modelled closely on - // ElasticSearch - see this issue for more - // details): - // - //
-  // {
-  //   total: // (required) total number of results (can be null)
-  //   hits: [ // (required) one entry for each result record
-  //     {
-  //        _score:   // (optional) match score for record
-  //        _type: // (optional) record type
-  //        _source: // (required) record/row object
-  //     } 
-  //   ],
-  //   facets: { // (optional) 
-  //     // facet results (as per )
-  //   }
-  // }
-  // 
- this.query = function(model, queryObj) {} -}; - -// ### makeRequest -// -// Just $.ajax but in any headers in the 'headers' attribute of this -// Backend instance. Example: -// -//
-// var jqxhr = this._makeRequest({
-//   url: the-url
-// });
-// 
-this.recline.Backend.makeRequest = function(data, headers) { - var extras = {}; - if (headers) { - extras = { - beforeSend: function(req) { - _.each(headers, function(value, key) { - req.setRequestHeader(key, value); - }); - } - }; - } - var data = _.extend(extras, data); - return $.ajax(data); -}; - -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; -this.recline.Backend.CSV = this.recline.Backend.CSV || {}; - -(function(my) { - // ## load - // - // Load data from a CSV file referenced in an HTMl5 file object returning the - // dataset in the callback - // - // @param options as for parseCSV below - my.load = function(file, callback, options) { - var encoding = options.encoding || 'UTF-8'; - - var metadata = { - id: file.name, - file: file - }; - var reader = new FileReader(); - // TODO - reader.onload = function(e) { - var dataset = my.csvToDataset(e.target.result, options); - callback(dataset); - }; - reader.onerror = function (e) { - alert('Failed to load file. Code: ' + e.target.error.code); - }; - reader.readAsText(file, encoding); - }; - - my.csvToDataset = function(csvString, options) { - var out = my.parseCSV(csvString, options); - fields = _.map(out[0], function(cell) { - return { id: cell, label: cell }; - }); - var data = _.map(out.slice(1), function(row) { - var _doc = {}; - _.each(out[0], function(fieldId, idx) { - _doc[fieldId] = row[idx]; - }); - return _doc; - }); - var dataset = recline.Backend.Memory.createDataset(data, fields); - return dataset; - }; - - // Converts a Comma Separated Values string into an array of arrays. - // Each line in the CSV becomes an array. - // - // Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats. - // - // @return The CSV parsed as an array - // @type Array - // - // @param {String} s The string to convert - // @param {Object} options Options for loading CSV including - // @param {Boolean} [trim=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported - // @param {String} [separator=','] Separator for CSV file - // Heavily based on uselesscode's JS CSV parser (MIT Licensed): - // thttp://www.uselesscode.org/javascript/csv/ - my.parseCSV= function(s, options) { - // Get rid of any trailing \n - s = chomp(s); - - var options = options || {}; - var trm = options.trim; - var separator = options.separator || ','; - var delimiter = options.delimiter || '"'; - - - var cur = '', // The character we are currently processing. - inQuote = false, - fieldQuoted = false, - field = '', // Buffer for building up the current field - row = [], - out = [], - i, - processField; - - processField = function (field) { - if (fieldQuoted !== true) { - // If field is empty set to null - if (field === '') { - field = null; - // If the field was not quoted and we are trimming fields, trim it - } else if (trm === true) { - field = trim(field); - } - - // Convert unquoted numbers to their appropriate types - if (rxIsInt.test(field)) { - field = parseInt(field, 10); - } else if (rxIsFloat.test(field)) { - field = parseFloat(field, 10); - } - } - return field; - }; - - for (i = 0; i < s.length; i += 1) { - cur = s.charAt(i); - - // If we are at a EOF or EOR - if (inQuote === false && (cur === separator || cur === "\n")) { - field = processField(field); - // Add the current field to the current row - row.push(field); - // If this is EOR append row to output and flush row - if (cur === "\n") { - out.push(row); - row = []; - } - // Flush the field buffer - field = ''; - fieldQuoted = false; - } else { - // If it's not a delimiter, add it to the field buffer - if (cur !== delimiter) { - field += cur; - } else { - if (!inQuote) { - // We are not in a quote, start a quote - inQuote = true; - fieldQuoted = true; - } else { - // Next char is delimiter, this is an escaped delimiter - if (s.charAt(i + 1) === delimiter) { - field += delimiter; - // Skip the next char - i += 1; - } else { - // It's not escaping, so end quote - inQuote = false; - } - } - } - } - } - - // Add the last field - field = processField(field); - row.push(field); - out.push(row); - - return out; - }; - - var rxIsInt = /^\d+$/, - rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, - // If a string has leading or trailing space, - // contains a comma double quote or a newline - // it needs to be quoted in CSV output - rxNeedsQuoting = /^\s|\s$|,|"|\n/, - trim = (function () { - // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists - if (String.prototype.trim) { - return function (s) { - return s.trim(); - }; - } else { - return function (s) { - return s.replace(/^\s*/, '').replace(/\s*$/, ''); - }; - } - }()); - - function chomp(s) { - if (s.charAt(s.length - 1) !== "\n") { - // Does not end with \n, just return string - return s; - } else { - // Remove the \n - return s.substring(0, s.length - 1); - } - } - - -}(this.recline.Backend.CSV)); -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; -this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; - -(function($, my) { - // ## DataProxy Backend - // - // For connecting to [DataProxy-s](http://github.com/okfn/dataproxy). - // - // When initializing the DataProxy backend you can set the following - // attributes in the options object: - // - // * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com - // - // Datasets using using this backend should set the following attributes: - // - // * url: (required) url-of-data-to-proxy - // * format: (optional) csv | xls (defaults to csv if not specified) - // - // Note that this is a **read-only** backend. - my.Backbone = function(options) { - var self = this; - this.__type__ = 'dataproxy'; - this.readonly = true; - - this.dataproxy_url = options && options.dataproxy_url ? options.dataproxy_url : 'http://jsonpdataproxy.appspot.com'; - - this.sync = function(method, model, options) { - if (method === "read") { - if (model.__type__ == 'Dataset') { - // Do nothing as we will get fields in query step (and no metadata to - // retrieve) - var dfd = $.Deferred(); - dfd.resolve(model); - return dfd.promise(); - } - } else { - alert('This backend only supports read operations'); - } - }; - - this.query = function(dataset, queryObj) { - var self = this; - var data = { - url: dataset.get('url'), - 'max-results': queryObj.size, - type: dataset.get('format') - }; - var jqxhr = $.ajax({ - url: this.dataproxy_url, - data: data, - dataType: 'jsonp' - }); - var dfd = $.Deferred(); - _wrapInTimeout(jqxhr).done(function(results) { - if (results.error) { - dfd.reject(results.error); - } - - // Rename duplicate fieldIds as each field name needs to be - // unique. - var seen = {}; - _.map(results.fields, function(fieldId, index) { - if (fieldId in seen) { - seen[fieldId] += 1; - results.fields[index] = fieldId + "("+seen[fieldId]+")"; - } else { - seen[fieldId] = 1; - } - }); - - dataset.fields.reset(_.map(results.fields, function(fieldId) { - return {id: fieldId}; - }) - ); - var _out = _.map(results.data, function(doc) { - var tmp = {}; - _.each(results.fields, function(key, idx) { - tmp[key] = doc[idx]; - }); - return tmp; - }); - dfd.resolve({ - total: null, - hits: _.map(_out, function(row) { - return { _source: row }; - }) - }); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - }; - }; - - // ## _wrapInTimeout - // - // Convenience method providing a crude way to catch backend errors on JSONP calls. - // Many of backends use JSONP and so will not get error messages and this is - // a crude way to catch those errors. - var _wrapInTimeout = function(ourFunction) { - var dfd = $.Deferred(); - var timeout = 5000; - var timer = setTimeout(function() { - dfd.reject({ - message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds' - }); - }, timeout); - ourFunction.done(function(arguments) { - clearTimeout(timer); - dfd.resolve(arguments); - }) - .fail(function(arguments) { - clearTimeout(timer); - dfd.reject(arguments); - }) - ; - return dfd.promise(); - } - -}(jQuery, this.recline.Backend.DataProxy)); -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; -this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; - -(function($, my) { - // ## ElasticSearch Wrapper - // - // Connecting to [ElasticSearch](http://www.elasticsearch.org/) endpoints. - // @param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running - // on localhost:9200 with index // twitter and type tweet it would be: - // - //
http://localhost:9200/twitter/tweet
- // - // @param {Object} options: set of options such as: - // - // * headers - {dict of headers to add to each request} - // * dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests) - my.Wrapper = function(endpoint, options) { - var self = this; - this.endpoint = endpoint; - this.options = _.extend({ - dataType: 'json' - }, - options); - - // ### mapping - // - // Get ES mapping for this type/table - // - // @return promise compatible deferred object. - this.mapping = function() { - var schemaUrl = self.endpoint + '/_mapping'; - var jqxhr = recline.Backend.makeRequest({ - url: schemaUrl, - dataType: this.options.dataType - }); - return jqxhr; - }; - - // ### get - // - // Get record corresponding to specified id - // - // @return promise compatible deferred object. - this.get = function(id) { - var base = this.endpoint + '/' + id; - return recline.Backend.makeRequest({ - url: base, - dataType: 'json' - }); - }; - - // ### upsert - // - // create / update a record to ElasticSearch backend - // - // @param {Object} doc an object to insert to the index. - // @return deferred supporting promise API - this.upsert = function(doc) { - var data = JSON.stringify(doc); - url = this.endpoint; - if (doc.id) { - url += '/' + doc.id; - } - return recline.Backend.makeRequest({ - url: url, - type: 'POST', - data: data, - dataType: 'json' - }); - }; - - // ### delete - // - // Delete a record from the ElasticSearch backend. - // - // @param {Object} id id of object to delete - // @return deferred supporting promise API - this.delete = function(id) { - url = this.endpoint; - url += '/' + id; - return recline.Backend.makeRequest({ - url: url, - type: 'DELETE', - dataType: 'json' - }); - }; - - this._normalizeQuery = function(queryObj) { - var self = this; - var queryInfo = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj); - var out = { - constant_score: { - query: {} - } - }; - if (!queryInfo.q) { - out.constant_score.query = { - match_all: {} - }; - } else { - out.constant_score.query = { - query_string: { - query: queryInfo.q - } - }; - } - if (queryInfo.filters && queryInfo.filters.length) { - out.constant_score.filter = { - and: [] - }; - _.each(queryInfo.filters, function(filter) { - out.constant_score.filter.and.push(self._convertFilter(filter)); - }); - } - return out; - }, - - this._convertFilter = function(filter) { - var out = {}; - out[filter.type] = {} - if (filter.type === 'term') { - out.term[filter.field] = filter.term.toLowerCase(); - } else if (filter.type === 'geo_distance') { - out.geo_distance[filter.field] = filter.point; - out.geo_distance.distance = filter.distance; - } - return out; - }, - - // ### query - // - // @return deferred supporting promise API - this.query = function(queryObj) { - var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj); - var queryNormalized = this._normalizeQuery(queryObj); - delete esQuery.q; - delete esQuery.filters; - esQuery.query = queryNormalized; - var data = {source: JSON.stringify(esQuery)}; - var url = this.endpoint + '/_search'; - var jqxhr = recline.Backend.makeRequest({ - url: url, - data: data, - dataType: this.options.dataType - }); - return jqxhr; - } - }; - - // ## ElasticSearch Backbone Backend - // - // Backbone connector for an ES backend. - // - // Usage: - // - // var backend = new recline.Backend.ElasticSearch(options); - // - // `options` are passed through to Wrapper - my.Backbone = function(options) { - var self = this; - var esOptions = options; - this.__type__ = 'elasticsearch'; - - // ### sync - // - // Backbone sync implementation for this backend. - // - // URL of ElasticSearch endpoint to use must be specified on the dataset - // (and on a Record via its dataset attribute) by the dataset having a - // url attribute. - this.sync = function(method, model, options) { - if (model.__type__ == 'Dataset') { - var endpoint = model.get('url'); - } else { - var endpoint = model.dataset.get('url'); - } - var es = new my.Wrapper(endpoint, esOptions); - if (method === "read") { - if (model.__type__ == 'Dataset') { - var dfd = $.Deferred(); - es.mapping().done(function(schema) { - // only one top level key in ES = the type so we can ignore it - var key = _.keys(schema)[0]; - var fieldData = _.map(schema[key].properties, function(dict, fieldName) { - dict.id = fieldName; - return dict; - }); - model.fields.reset(fieldData); - dfd.resolve(model); - }) - .fail(function(arguments) { - dfd.reject(arguments); - }); - return dfd.promise(); - } else if (model.__type__ == 'Record') { - return es.get(model.dataset.id); - } - } else if (method === 'update') { - if (model.__type__ == 'Record') { - return es.upsert(model.toJSON()); - } - } else if (method === 'delete') { - if (model.__type__ == 'Record') { - return es.delete(model.id); - } - } - }; - - // ### query - // - // query the ES backend - this.query = function(model, queryObj) { - var dfd = $.Deferred(); - var url = model.get('url'); - var es = new my.Wrapper(url, esOptions); - var jqxhr = es.query(queryObj); - // TODO: fail case - jqxhr.done(function(results) { - _.each(results.hits.hits, function(hit) { - if (!('id' in hit._source) && hit._id) { - hit._source.id = hit._id; - } - }); - if (results.facets) { - results.hits.facets = results.facets; - } - dfd.resolve(results.hits); - }).fail(function(errorObj) { - var out = { - title: 'Failed: ' + errorObj.status + ' code', - message: errorObj.responseText - }; - dfd.reject(out); - }); - return dfd.promise(); - }; - }; - -}(jQuery, this.recline.Backend.ElasticSearch)); - -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; -this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; - -(function($, my) { - - // ## Google spreadsheet backend - // - // Connect to Google Docs spreadsheet. - // - // Dataset must have a url attribute pointing to the Gdocs - // spreadsheet's JSON feed e.g. - // - //
-  // var dataset = new recline.Model.Dataset({
-  //     url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
-  //   },
-  //   'gdocs'
-  // );
-  // 
- my.Backbone = function() { - var self = this; - this.__type__ = 'gdocs'; - this.readonly = true; - - this.sync = function(method, model, options) { - var self = this; - if (method === "read") { - var dfd = $.Deferred(); - dfd.resolve(model); - return dfd.promise(); - } - }; - - this.query = function(dataset, queryObj) { - var dfd = $.Deferred(); - if (dataset._dataCache) { - dfd.resolve(dataset._dataCache); - } else { - loadData(dataset.get('url')).done(function(result) { - dataset.fields.reset(result.fields); - // cache data onto dataset (we have loaded whole gdoc it seems!) - dataset._dataCache = self._formatResults(dataset, result.data); - dfd.resolve(dataset._dataCache); - }); - } - return dfd.promise(); - }; - - this._formatResults = function(dataset, data) { - var fields = _.pluck(dataset.fields.toJSON(), 'id'); - // zip the fields with the data rows to produce js objs - // TODO: factor this out as a common method with other backends - var objs = _.map(data, function (d) { - var obj = {}; - _.each(_.zip(fields, d), function (x) { - obj[x[0]] = x[1]; - }); - return obj; - }); - var out = { - total: objs.length, - hits: _.map(objs, function(row) { - return { _source: row } - }) - } - return out; - }; - }; - - // ## loadData - // - // loadData from a google docs URL - // - // @return object with two attributes - // - // * fields: array of objects - // * data: array of arrays - var loadData = function(url) { - var dfd = $.Deferred(); - var url = my.getSpreadsheetAPIUrl(url); - var out = { - fields: [], - data: [] - } - $.getJSON(url, function(d) { - result = my.parseData(d); - result.fields = _.map(result.fields, function(fieldId) { - return {id: fieldId}; - }); - dfd.resolve(result); - }); - return dfd.promise(); - }; - - // ## parseData - // - // Parse data from Google Docs API into a reasonable form - // - // :options: (optional) optional argument dictionary: - // columnsToUse: list of columns to use (specified by field names) - // colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). - // :return: tabular data object (hash with keys: field and data). - // - // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. - my.parseData = function(gdocsSpreadsheet) { - var options = {}; - if (arguments.length > 1) { - options = arguments[1]; - } - var results = { - 'fields': [], - 'data': [] - }; - // default is no special info on type of columns - var colTypes = {}; - if (options.colTypes) { - colTypes = options.colTypes; - } - if (gdocsSpreadsheet.feed.entry.length > 0) { - for (var k in gdocsSpreadsheet.feed.entry[0]) { - if (k.substr(0, 3) == 'gsx') { - var col = k.substr(4); - results.fields.push(col); - } - } - } - - // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) - var rep = /^([\d\.\-]+)\%$/; - $.each(gdocsSpreadsheet.feed.entry, function (i, entry) { - var row = []; - for (var k in results.fields) { - var col = results.fields[k]; - var _keyname = 'gsx$' + col; - var value = entry[_keyname]['$t']; - // if labelled as % and value contains %, convert - if (colTypes[col] == 'percent') { - if (rep.test(value)) { - var value2 = rep.exec(value); - var value3 = parseFloat(value2); - value = value3 / 100; - } - } - row.push(value); - } - results.data.push(row); - }); - return results; - }; - - // Convenience function to get GDocs JSON API Url from standard URL - my.getSpreadsheetAPIUrl = function(url) { - if (url.indexOf('feeds/list') != -1) { - return url; - } else { - // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0 - var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/; - var matches = url.match(regex); - if (matches) { - var key = matches[1]; - var worksheet = 1; - var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json'; - return out; - } else { - alert('Failed to extract gdocs key from ' + url); - } - } - }; -}(jQuery, this.recline.Backend.GDocs)); - -this.recline = this.recline || {}; -this.recline.Backend = this.recline.Backend || {}; -this.recline.Backend.Memory = this.recline.Backend.Memory || {}; - -(function($, my) { - // ## createDataset - // - // Convenience function to create a simple 'in-memory' dataset in one step. - // - // @param data: list of hashes for each record/row in the data ({key: - // value, key: value}) - // @param fields: (optional) list of field hashes (each hash defining a hash - // as per recline.Model.Field). If fields not specified they will be taken - // from the data. - // @param metadata: (optional) dataset metadata - see recline.Model.Dataset. - // If not defined (or id not provided) id will be autogenerated. - my.createDataset = function(data, fields, metadata) { - var wrapper = new my.Store(data, fields); - var backend = new my.Backbone(); - var dataset = new recline.Model.Dataset(metadata, backend); - dataset._dataCache = wrapper; - dataset.fetch(); - dataset.query(); - return dataset; - }; - - // ## Data Wrapper - // - // Turn a simple array of JS objects into a mini data-store with - // functionality like querying, faceting, updating (by ID) and deleting (by - // ID). - // - // @param data list of hashes for each record/row in the data ({key: - // value, key: value}) - // @param fields (optional) list of field hashes (each hash defining a field - // as per recline.Model.Field). If fields not specified they will be taken - // from the data. - my.Store = function(data, fields) { - var self = this; - this.data = data; - if (fields) { - this.fields = fields; - } else { - if (data) { - this.fields = _.map(data[0], function(value, key) { - return {id: key}; - }); - } - } - - this.update = function(doc) { - _.each(self.data, function(internalDoc, idx) { - if(doc.id === internalDoc.id) { - self.data[idx] = doc; - } - }); - }; - - this.delete = function(doc) { - var newdocs = _.reject(self.data, function(internalDoc) { - return (doc.id === internalDoc.id); - }); - this.data = newdocs; - }; - - this.query = function(queryObj) { - var numRows = queryObj.size || this.data.length; - var start = queryObj.from || 0; - var results = this.data; - results = this._applyFilters(results, queryObj); - results = this._applyFreeTextQuery(results, queryObj); - // not complete sorting! - _.each(queryObj.sort, function(sortObj) { - var fieldName = _.keys(sortObj)[0]; - results = _.sortBy(results, function(doc) { - var _out = doc[fieldName]; - return _out; - }); - if (sortObj[fieldName].order == 'desc') { - results.reverse(); - } - }); - var total = results.length; - var facets = this.computeFacets(results, queryObj); - results = results.slice(start, start+numRows); - return { - total: total, - records: results, - facets: facets - }; - }; - - // in place filtering - this._applyFilters = function(results, queryObj) { - _.each(queryObj.filters, function(filter) { - // if a term filter ... - if (filter.type === 'term') { - results = _.filter(results, function(doc) { - return (doc[filter.field] == filter.term); - }); - } - }); - return results; - }; - - // we OR across fields but AND across terms in query string - this._applyFreeTextQuery = function(results, queryObj) { - if (queryObj.q) { - var terms = queryObj.q.split(' '); - results = _.filter(results, function(rawdoc) { - var matches = true; - _.each(terms, function(term) { - var foundmatch = false; - _.each(self.fields, function(field) { - var value = rawdoc[field.id]; - if (value !== null) { value = value.toString(); } - // TODO regexes? - foundmatch = foundmatch || (value.toLowerCase() === term.toLowerCase()); - // TODO: early out (once we are true should break to spare unnecessary testing) - // if (foundmatch) return true; - }); - matches = matches && foundmatch; - // TODO: early out (once false should break to spare unnecessary testing) - // if (!matches) return false; - }); - return matches; - }); - } - return results; - }; - - this.computeFacets = function(records, queryObj) { - var facetResults = {}; - if (!queryObj.facets) { - return facetResults; - } - _.each(queryObj.facets, function(query, facetId) { - // TODO: remove dependency on recline.Model - facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON(); - facetResults[facetId].termsall = {}; - }); - // faceting - _.each(records, function(doc) { - _.each(queryObj.facets, function(query, facetId) { - var fieldId = query.terms.field; - var val = doc[fieldId]; - var tmp = facetResults[facetId]; - if (val) { - tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1; - } else { - tmp.missing = tmp.missing + 1; - } - }); - }); - _.each(queryObj.facets, function(query, facetId) { - var tmp = facetResults[facetId]; - var terms = _.map(tmp.termsall, function(count, term) { - return { term: term, count: count }; - }); - tmp.terms = _.sortBy(terms, function(item) { - // want descending order - return -item.count; - }); - tmp.terms = tmp.terms.slice(0, 10); - }); - return facetResults; - }; - }; - - - // ## Backbone - // - // Backbone connector for memory store attached to a Dataset object - my.Backbone = function() { - this.__type__ = 'memory'; - this.sync = function(method, model, options) { - var self = this; - var dfd = $.Deferred(); - if (method === "read") { - if (model.__type__ == 'Dataset') { - model.fields.reset(model._dataCache.fields); - dfd.resolve(model); - } - return dfd.promise(); - } else if (method === 'update') { - if (model.__type__ == 'Record') { - model.dataset._dataCache.update(model.toJSON()); - dfd.resolve(model); - } - return dfd.promise(); - } else if (method === 'delete') { - if (model.__type__ == 'Record') { - model.dataset._dataCache.delete(model.toJSON()); - dfd.resolve(model); - } - return dfd.promise(); - } else { - alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model); - } - }; - - this.query = function(model, queryObj) { - var dfd = $.Deferred(); - var results = model._dataCache.query(queryObj); - var hits = _.map(results.records, function(row) { - return { _source: row }; - }); - var out = { - total: results.total, - hits: hits, - facets: results.facets - }; - dfd.resolve(out); - return dfd.promise(); - }; - }; - -}(jQuery, this.recline.Backend.Memory)); diff --git a/make b/make index c2b75c17..9d71eb81 100755 --- a/make +++ b/make @@ -5,7 +5,7 @@ import os def cat(): print("** Combining js files") - cmd = 'cat src/*.js > dist/recline.js' + cmd = 'ls src/*.js | grep -v couchdb | xargs cat > dist/recline.js' os.system(cmd) print("** Combining css files") cmd = 'cat css/*.css > dist/recline.css' From c2f5debf1f1c4d3c58889cb8a25db05afd7222e1 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Wed, 27 Jun 2012 10:10:45 +0100 Subject: [PATCH 11/14] [docs,app,demos/multiview][m]: convert app back to being a demo of multiview (app proper is now in dataexplorer repo). --- _includes/recline-deps.html | 41 +++-- _layouts/default.html | 8 +- app/built.html | 170 --------------------- app/images/bg_gradient.gif | Bin 87 -> 0 bytes app/images/couch.png | Bin 2536 -> 0 bytes app/images/large-spinner.gif | Bin 3208 -> 0 bytes app/index.html | 265 --------------------------------- app/js/app.js | 246 ------------------------------ app/style/demo.css | 21 --- demos.html => demos/index.html | 5 +- demos/multiview/app.js | 84 +++++++++++ demos/multiview/index.html | 24 +++ index.html | 2 +- 13 files changed, 133 insertions(+), 733 deletions(-) delete mode 100644 app/built.html delete mode 100755 app/images/bg_gradient.gif delete mode 100755 app/images/couch.png delete mode 100755 app/images/large-spinner.gif delete mode 100644 app/index.html delete mode 100755 app/js/app.js delete mode 100644 app/style/demo.css rename demos.html => demos/index.html (71%) create mode 100755 demos/multiview/app.js create mode 100644 demos/multiview/index.html diff --git a/_includes/recline-deps.html b/_includes/recline-deps.html index 71b9c45a..7ffe11d8 100644 --- a/_includes/recline-deps.html +++ b/_includes/recline-deps.html @@ -1,33 +1,26 @@ - + - + - - - - + - - - - - - - + + + + + + + + + + + + - - + diff --git a/_layouts/default.html b/_layouts/default.html index 16d91ddc..d4573674 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -27,17 +27,17 @@ - Recline.js – relax with your data + Recline.js – relax with your data