diff options
-rw-r--r-- | lib/caldav/query_builder.js | 318 | ||||
-rw-r--r-- | test/caldav/query_builder_test.js | 153 |
2 files changed, 471 insertions, 0 deletions
diff --git a/lib/caldav/query_builder.js b/lib/caldav/query_builder.js new file mode 100644 index 0000000..3d7c9eb --- /dev/null +++ b/lib/caldav/query_builder.js @@ -0,0 +1,318 @@ +/** +@namespace +*/ +(function(module, ns) { + var Template = ns.require('template'); + + /** + * Builds a node of a calendar-data or filter xml element. + * + * @param {QueryBuilder} builder instance. + * @param {String} name component/prop name (like RRULE/VEVENT). + * @param {Boolean} isProp is this node a property tag? + */ + function Node(builder, name, isProp) { + this.name = name; + this.builder = builder; + this.isProp = !!isProp; + + this.comps = Object.create(null); + this.props = Object.create(null); + } + + Node.prototype = { + + /** + * Hook for adding custom node content. + * (for unsupported or custom filters) + * + * Usually you never want to use this. + * + * @type {Null|Array} + */ + content: null, + + /** + * Appends custom string content into node. + * + * @param {String} string content. + */ + appendString: function(string) { + if (!this.content) { + this.content = []; + } + + if (typeof(string) !== 'string') { + string = string.toString(); + } + + content.push(string); + }, + + _timeRange: null, + + /** + * Adds a time range element to the node. + * + * Example: + * + * var node; + * + * // key/values not validated or converted + * // but directly piped into the time-range element. + * node.setTimeRange({ + * start: '20060104T000000Z', + * end: '20060105T000000Z' + * }); + * + * // when null removes element + * node.setTimeRange(null); + * + * @param {Object|Null} range time range or null to remove. + */ + setTimeRange: function(range) { + this._timeRange = range; + }, + + /** + * Removes a property from the output. + * @param {String} name prop. + */ + removeProp: function(name) { + delete this.props[name]; + }, + + /** + * Removes a component from the output. + * + * @param {String} name comp. + */ + removeComp: function(name) { + delete this.comps[name]; + }, + + _addNodes: function(type, nodes) { + // return value when is array + var result = this; + + if (!Array.isArray(nodes)) { + // clear out the return value as we will + // now use the first node. + result = null; + } + + nodes = (Array.isArray(nodes)) ? nodes : [nodes]; + + var idx = 0; + var len = nodes.length; + var name; + var node; + + for (; idx < len; idx++) { + name = nodes[idx]; + node = new Node(this.builder, name, type === 'props'); + this[type][name] = node; + } + + // when we where not given an array of nodes + // assume we want one specific one so set that + // as the return value. + if (!result) + result = node; + + return result; + }, + + /** + * Adds one or more props. + * If property already exists will not add + * duplicates but return the existing property. + * + * @param {String|Array[String]} prop one or more properties to add. + * @return {Node|Self} returns a node or self when given an array. + */ + prop: function(prop) { + return this._addNodes('props', prop); + }, + + /** + * Adds one or more comp. + * If comp already exists will not add + * duplicates but return the existing comp. + * + * @param {String|Array[String]} comp one or more components to add. + * @return {Node|Self} returns a node or self when given an array. + */ + comp: function(comp) { + return this._addNodes('comps', comp); + }, + + xmlAttributes: function() { + return { name: this.name }; + }, + + /** + * Transform tree into a string. + * + * NOTE: order is not preserved at all here. + * It is highly unlikely that order is a + * factor for calendar-data or filter + * but this is fair warning for other uses. + */ + toString: function() { + var content = ''; + var key; + var template = this.builder.template; + + if (this._timeRange) { + content += template.tag( + ['caldav', 'time-range'], + this._timeRange + ); + } + + // render out children + for (key in this.props) { + content += this.props[key].toString(); + } + + for (key in this.comps) { + content += this.comps[key].toString(); + } + + if (this.content) { + content += this.content.join(''); + } + + // determine the tag name + var tag; + if (this.isProp) { + tag = this.builder.propTag; + } else { + tag = this.builder.compTag; + } + + // build the xml element and return it. + return template.tag( + tag, + this.xmlAttributes(), + content + ); + } + }; + + /** + * Query builder can be used to build xml document fragments + * for calendar-data & calendar-filter. + * (and any other xml document with a similar structure) + * + * Options: + * - template: (Caldav.Template instance) + * - tag: container tag (like 'calendar-data') + * - attributes: attributes for root + * - compTag: name of comp[onent] tag name (like 'comp') + * - propTag: name of property tag (like 'prop') + * + * @param {Object} options query builder options. + */ + function QueryBuilder(options) { + if (!options) + options = {}; + + if (!(options.template instanceof Template)) { + throw new TypeError( + '.template must be an instance' + + ' of Caldav.Template given "' + options.template + '"' + ); + } + + for (var key in options) { + if (options.hasOwnProperty(key)) { + this[key] = options[key]; + } + } + } + + QueryBuilder.prototype = { + tag: ['caldav', 'calendar-data'], + + compTag: ['caldav', 'comp'], + + propTag: ['caldav', 'prop'], + + attributes: null, + + _limitRecurrenceSet: null, + + /** + * Adds the recurrence set limit child to the query. + * Directly maps to the caldav 'limit-recurrence-set' element. + * + * Examples: + * + * var builder; + * + * // no validation or formatting is done. + * builder.setRecurrenceSetLimit({ + * start: '20060103T000000Z', + * end: '20060103T000000Z' + * }); + * + * // use null to clear value + * builder.setRecurrenceSetLimit(null); + * + * @param {Object|Null} limit see above. + */ + setRecurrenceSetLimit: function(limit) { + this._limitRecurrenceSet = limit; + }, + + /** + * @param {String} name component name (like VCALENDAR). + * @return {QueryBuilder.Node} node instance. + */ + setComp: function(name) { + return this._compRoot = new Node(this, name); + }, + + /** + * Returns the root node of the document fragment. + */ + getComp: function() { + return this._compRoot; + }, + + toString: function() { + var content = ''; + var comp = this.getComp(); + + if (this._limitRecurrenceSet) { + content += this.template.tag( + ['caldav', 'limit-recurrence-set'], + this._limitRecurrenceSet + ); + } + + if (comp) { + content += comp.toString(); + } + + return this.template.tag( + this.tag, + this.attributes, + content + ); + } + + }; + + QueryBuilder.Node = Node; + module.exports = QueryBuilder; + +}.apply( + this, + (this.Caldav) ? + [Caldav('query_builder'), Caldav] : + [module, require('./caldav')] +)); + diff --git a/test/caldav/query_builder_test.js b/test/caldav/query_builder_test.js new file mode 100644 index 0000000..2e8bd8a --- /dev/null +++ b/test/caldav/query_builder_test.js @@ -0,0 +1,153 @@ +testSupport.lib('template'); +testSupport.lib('query_builder'); + +suite('caldav/query_builder', function() { + + // classes + var Builder; + var Template; + + // instances + var subject; + var template; + + suiteSetup(function() { + Builder = Caldav.require('query_builder'); + Template = Caldav.require('template'); + }); + + setup(function() { + template = new Template('container'); + }); + + test('no template given', function() { + assert.throws(function() { + new Builder(); + }, TypeError); + }); + + test('empty document', function() { + var subject = new Builder({ template: template }); + var out = subject.toString(); + + assert.equal(out, '<N0:calendar-data />'); + }); + + test('#setRecurrenceSetLimit', function() { + var subject = new Builder({ template: template }); + + subject.setRecurrenceSetLimit({ + start: 'a', + end: 'b' + }); + + var expected = [ + '<N0:calendar-data>', + '<N0:limit-recurrence-set start="a" end="b" />', + '</N0:calendar-data>' + ].join(''); + + var out = subject.toString(); + assert.equal(out, expected); + }); + + // based on (calendar-data): + // http://pretty-rfc.herokuapp.com/RFC4791#example-partial-retrieval-of-events-by-time-range + suite('spec test - calendar data', function() { + var expected = [ + '<N0:calendar-data>', + '<N0:comp name="VCALENDAR">', + '<N0:prop name="VERSION" />', + '<N0:comp name="VTIMEZONE" />', + '<N0:comp name="VEVENT">', + '<N0:prop name="SUMMARY" />', + '<N0:prop name="UID" />', + '<N0:prop name="DTSTART" />', + '<N0:prop name="DTEND" />', + '<N0:prop name="DURATION" />', + '<N0:prop name="RRULE" />', + '<N0:prop name="RDATE" />', + '<N0:prop name="EXRULE" />', + '<N0:prop name="EXDATE" />', + '<N0:prop name="RECURRENCE-ID" />', + '</N0:comp>', + '</N0:comp>', + '</N0:calendar-data>' + ].join(''); + + test('output', function() { + var builder = new Builder({ + template: template, + tag: ['caldav', 'calendar-data'], + compTag: ['caldav', 'comp'], + propTag: ['caldav', 'prop'] + }); + + // set the root component + var cal = builder.setComp('VCALENDAR'); + cal.prop('VERSION'); + + // vtimezone + cal.comp('VTIMEZONE'); + + // vevent + var event = cal.comp('VEVENT'); + + //shortcut method + event.prop([ + 'SUMMARY', + 'UID', + 'DTSTART', + 'DTEND', + 'DURATION', + 'RRULE', + 'RDATE', + 'EXRULE', + 'EXDATE', + 'RECURRENCE-ID' + ]); + + var output = builder.toString(); + assert.deepEqual( + output.trim(), + expected.trim() + ); + }); + }); + + // based on (calendar-filter): + // http://pretty-rfc.herokuapp.com/RFC4791#example-partial-retrieval-of-events-by-time-range + suite('spec test - filter', function() { + var expected = [ + '<N0:filter>', + '<N0:comp-filter name="VCALENDAR">', + '<N0:comp-filter name="VEVENT">', + '<N0:time-range start="20060104T000000Z" ', + 'end="20060105T000000Z" />', + '</N0:comp-filter>', + '</N0:comp-filter>', + '</N0:filter>' + ].join(''); + + test('output', function() { + var filter = new Builder({ + tag: ['caldav', 'filter'], + compTag: ['caldav', 'comp-filter'], + propTag: ['caldav', 'prop-filter'], + template: template + }); + + var event = filter.setComp('VCALENDAR'). + comp('VEVENT'); + + event.setTimeRange({ + start: '20060104T000000Z', + end: '20060105T000000Z' + }); + + var output = filter.toString(); + assert.equal(expected, output); + }); + + }); +}); |