aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--lib/caldav/query_builder.js318
-rw-r--r--test/caldav/query_builder_test.js153
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);
+ });
+
+ });
+});