diff options
Diffstat (limited to 'test')
-rw-r--r-- | test/helper.js | 2 | ||||
-rw-r--r-- | test/support/ical.js | 1738 |
2 files changed, 1551 insertions, 189 deletions
diff --git a/test/helper.js b/test/helper.js index efb2487..64346bd 100644 --- a/test/helper.js +++ b/test/helper.js @@ -175,8 +175,6 @@ testSupport.lib('sax/dav_response'); testSupport.lib('request/abstract'); testSupport.lib('template'); - testSupport.lib('templates/calendar_data'); - testSupport.lib('templates/calendar_filter'); testSupport.helper('fake_xhr'); testSupport.lib('request/propfind'); diff --git a/test/support/ical.js b/test/support/ical.js index 5012ca0..8ffd57c 100644 --- a/test/support/ical.js +++ b/test/support/ical.js @@ -29,6 +29,66 @@ ICAL.helpers = { }; }, + /** + * Creates or returns a class instance + * of a given type with the initialization + * data if the data is not already an instance + * of the given type. + * + * + * Example: + * + * var time = new ICAL.icaltime(...); + * var result = ICAL.helpers.formatClassType(time, ICAL.icaltime); + * + * (result instanceof ICAL.icaltime) + * // => true + * + * result = ICAL.helpers.formatClassType({}, ICAL.icaltime); + * (result isntanceof ICAL.icaltime) + * // => true + * + * + * @param {Object} data object initialization data. + * @param {Object} type object type (like ICAL.icaltime). + */ + formatClassType: function formatClassType(data, type) { + if (typeof(data) === 'undefined') + return undefined; + + if (data instanceof type) { + return data; + } + return new type(data); + }, + + binsearchInsert: function(list, seekVal, cmpfunc) { + if (!list.length) + return 0; + + var low = 0, high = list.length - 1, + mid, cmpval; + + while (low <= high) { + mid = low + Math.floor((high - low) / 2); + cmpval = cmpfunc(seekVal, list[mid]); + + if (cmpval < 0) + high = mid - 1; + else if (cmpval > 0) + low = mid + 1; + else + break; + } + + if (cmpval < 0) + return mid; // insertion is displacing, so use mid outright. + else if (cmpval > 0) + return mid + 1; + else + return mid; + }, + dumpn: function() { if (!ICAL.debug) { return null; @@ -77,7 +137,7 @@ ICAL.helpers = { var result = {}; for (var name in aSrc) { if (aSrc.hasOwnProperty(name)) { - dump("Cloning " + name + "\n"); + this.dumpn("Cloning " + name + "\n"); if (aDeep) { result[name] = ICAL.helpers.clone(aSrc[name], true); } else { @@ -253,9 +313,12 @@ ICAL.helpers = { constructor: ParserError }; - var parser = {}; + var parser = { + Error: ParserError + }; ICAL.icalparser = parser; + parser.lexContentLine = function lexContentLine(aState) { // contentline = name *(";" param ) ":" value CRLF // The corresponding json object will be: @@ -422,8 +485,12 @@ ICAL.helpers = { } if ("parameters" in aLineData && "VALUE" in aLineData.parameters) { - ICAL.helpers.dumpn("VAAAA: " + aLineData.parameters.VALUE.toString()); - valueType = aLineData.parameters.VALUE.value.toUpperCase(); + var valueParam = aLineData.parameters.VALUE; + if (typeof(valueParam) === 'string') { + valueType = aLineData.parameters.VALUE.toUpperCase(); + } else if(typeof(valueParam) === 'object') { + valueType = valueParam.value.toUpperCase(); + } } if (!(valueType in ICAL.design.value)) { @@ -505,7 +572,7 @@ ICAL.helpers = { for (var param in reqParam) { if (!("parameters" in aLineData) || !(param in aLineData.parameters) || - aLineData.parameters[param].value != reqParam[param]) { + aLineData.parameters[param] != reqParam[param]) { throw new ParserError(aLineData, "Value requires " + param + "=" + valueData.requireParam[param]); @@ -1624,6 +1691,31 @@ ICAL.design = { return props; }, + /** + * Adds or replaces a property with a given value. + * Suitable for use when updating properties which + * are expected to only have a single value (like DTSTART, SUMMARY, etc..) + * + * @param {String} aName property name. + * @param {Object} aValue property value. + */ + updatePropertyWithValue: function updatePropertyWithValue(aName, aValue) { + if (!this.hasProperty(aName)) { + return this.addPropertyWithValue(aName, aValue); + } + + var prop = this.getFirstProperty(aName); + + var lineData = ICAL.icalparser.detectValueType({ + name: aName.toUpperCase(), + value: aValue + }); + + prop.setValues(lineData.value, lineData.type); + + return prop; + }, + addPropertyWithValue: function addStringProperty(aName, aValue) { var ucName = aName.toUpperCase(); var lineData = ICAL.icalparser.detectValueType({ @@ -1725,7 +1817,7 @@ ICAL.design = { }; ICAL.icalcomponent.fromString = function icalcomponent_from_string(str) { - return ICAL.toJSON(str, true); + return ICAL.icalcomponent.fromData(ICAL.parse(str)); }; ICAL.icalcomponent.fromData = function icalcomponent_from_data(aData) { @@ -2757,7 +2849,7 @@ ICAL.design = { this.reset(); } else { if (useUTC) { - this.zone = ICAL.icaltimzone.utc_timezone; + this.zone = ICAL.icaltimezone.utc_timezone; this.year = aDate.getUTCFullYear(); this.month = aDate.getUTCMonth() + 1; this.day = aDate.getUTCDate(); @@ -2803,9 +2895,21 @@ ICAL.design = { this.isDate = aData.isDate; } - if (aData && "timezone" in aData && aData.timezone == "Z") { - this.zone = ICAL.icaltimezone.utc_timezone; + if (aData && "timezone" in aData) { + var timezone = aData.timezone; + + //TODO: replace with timezone service + switch (timezone) { + case 'Z': + case ICAL.icaltimezone.utc_timezone.tzid: + this.zone = ICAL.icaltimezone.utc_timezone; + break; + case ICAL.icaltimezone.local_timezone.tzid: + this.zone = ICAL.icaltimezone.local_timezone; + break; + } } + if (aData && "zone" in aData) { this.zone = aData.zone; } @@ -2825,7 +2929,7 @@ ICAL.design = { return this; }, - day_of_week: function icaltime_day_of_week() { + dayOfWeek: function icaltime_dayOfWeek() { // Using Zeller's algorithm var q = this.day; var m = this.month + (this.month < 3 ? 12 : 0); @@ -2843,21 +2947,21 @@ ICAL.design = { return h; }, - day_of_year: function icaltime_day_of_year() { + dayOfYear: function icaltime_dayOfYear() { var is_leap = (ICAL.icaltime.is_leap_year(this.year) ? 1 : 0); var diypm = ICAL.icaltime._days_in_year_passed_month; return diypm[is_leap][this.month - 1] + this.day; }, - start_of_week: function start_of_week() { + startOfWeek: function startOfWeek() { var result = this.clone(); - result.day -= this.day_of_week() - 1; + result.day -= this.dayOfWeek() - 1; return result.normalize(); }, end_of_week: function end_of_week() { var result = this.clone(); - result.day += 7 - this.day_of_week(); + result.day += 7 - this.dayOfWeek(); return result.normalize(); }, @@ -2873,7 +2977,7 @@ ICAL.design = { end_of_month: function end_of_month() { var result = this.clone(); - result.day = ICAL.icaltime.days_in_month(result.month, result.year); + result.day = ICAL.icaltime.daysInMonth(result.month, result.year); result.isDate = true; result.hour = 0; result.minute = 0; @@ -2905,62 +3009,138 @@ ICAL.design = { start_doy_week: function start_doy_week(aFirstDayOfWeek) { var firstDow = aFirstDayOfWeek || ICAL.icaltime.SUNDAY; - var delta = this.day_of_week() - firstDow; + var delta = this.dayOfWeek() - firstDow; if (delta < 0) delta += 7; - return this.day_of_year() - delta; + return this.dayOfYear() - delta; }, - nth_weekday: function icaltime_nth_weekday(aDayOfWeek, aPos) { - var days_in_month = ICAL.icaltime.days_in_month(this.month, this.year); + /** + * Finds the nthWeekDay relative to the current month (not day). + * The returned value is a day relative the month that this + * month belongs to so 1 would indicate the first of the month + * and 40 would indicate a day in the following month. + * + * @param {Numeric} aDayOfWeek day of the week see the day name constants. + * @param {Numeric} aPos nth occurrence of a given week day + * values of 1 and 0 both indicate the first + * weekday of that type. aPos may be either positive + * or negative. + * + * @return {Numeric} numeric value indicating a day relative + * to the current month of this time object. + */ + nthWeekDay: function icaltime_nthWeekDay(aDayOfWeek, aPos) { + var daysInMonth = ICAL.icaltime.daysInMonth(this.month, this.year); var weekday; var pos = aPos; - var otherday = this.clone(); + var start = 0; + + var otherDay = this.clone(); if (pos >= 0) { - otherday.day = 1; - var start_dow = otherday.day_of_week(); + otherDay.day = 1; + // because 0 means no position has been given + // 1 and 0 indicate the same day. if (pos != 0) { + // remove the extra numeric value pos--; } - weekday = aDayOfWeek - start_dow + 1; + // set current start offset to current day. + start = otherDay.day; - if (weekday <= 0) { - weekday += 7; - } + // find the current day of week + var startDow = otherDay.dayOfWeek(); + + // calculate the difference between current + // day of the week and desired day of the week + var offset = aDayOfWeek - startDow; + + + // if the offset goes into the past + // week we add 7 so its goes into the next + // week. We only want to go forward in time here. + if (offset < 0) + // this is really important otherwise we would + // end up with dates from in the past. + offset += 7; + + // add offset to start so start is the same + // day of the week as the desired day of week. + start += offset; + + // because we are going to add (and multiply) + // the numeric value of the day we subtract it + // from the start position so not to add it twice. + start -= aDayOfWeek; + + // set week day + weekday = aDayOfWeek; } else { - otherday.day = days_in_month; - var end_dow = otherday.day_of_week(); + + // then we set it to the last day in the current month + otherDay.day = daysInMonth; + + // find the ends weekday + var endDow = otherDay.dayOfWeek(); pos++; - weekday = (end_dow - dow); + weekday = (endDow - aDayOfWeek); if (weekday < 0) { weekday += 7; } - weekday = days_in_month - weekday; + weekday = daysInMonth - weekday; } weekday += pos * 7; - return weekday; + return start + weekday; + }, + + /** + * Checks if current time is the nthWeekDay. + * Relative to the current month. + * + * Will always return false when rule resolves + * outside of current month. + * + * @param {Numeric} aDayOfWeek day of week. + * @param {Numeric} aPos position. + * @param {Numeric} aMax maximum valid day. + */ + isNthWeekDay: function(aDayOfWeek, aPos) { + var dow = this.dayOfWeek(); + + if (aPos === 0 && dow === aDayOfWeek) { + return true; + } + + // get pos + var day = this.nthWeekDay(aDayOfWeek, aPos); + + if (day === this.day) { + return true; + } + + return false; }, week_number: function week_number(aWeekStart) { // This function courtesty of Julian Bucknall, published under the MIT license // http://www.boyet.com/articles/publishedarticles/calculatingtheisoweeknumb.html - var doy = this.day_of_year(); - var dow = this.day_of_week(); + var doy = this.dayOfYear(); + var dow = this.dayOfWeek(); var year = this.year; var week1; var dt = this.clone(); dt.isDate = true; - var first_dow = dt.day_of_week(); + var first_dow = dt.dayOfWeek(); var isoyear = this.year; if (dt.month == 12 && dt.day > 28) { @@ -3014,23 +3194,23 @@ ICAL.design = { dur.hours = this.hour - aDate.hour; if (this.year == aDate.year) { - var this_doy = this.day_of_year(); - var that_doy = aDate.day_of_year(); + var this_doy = this.dayOfYear(); + var that_doy = aDate.dayOfYear(); dur.days = this_doy - that_doy; } else if (this.year < aDate.year) { var days_left_thisyear = 365 + (ICAL.icaltime.is_leap_year(this.year) ? 1 : 0) - - this.day_of_year(); + this.dayOfYear(); - dur.days -= days_left_thisyear + aDate.day_of_year(); + dur.days -= days_left_thisyear + aDate.dayOfYear(); dur.days -= leap_years_between(this.year + 1, aDate.year); dur.days -= 365 * (aDate.year - this.year - 1); } else { var days_left_thatyear = 365 + (ICAL.icaltime.is_leap_year(aDate.year) ? 1 : 0) - - aDate.day_of_year(); + aDate.dayOfYear(); - dur.days += days_left_thatyear + this.day_of_year(); + dur.days += days_left_thatyear + this.dayOfYear(); dur.days += leap_years_between(aDate.year + 1, this.year); dur.days += 365 * (this.year - aDate.year - 1); } @@ -3169,7 +3349,7 @@ ICAL.design = { var second, minute, hour, day; var minutes_overflow, hours_overflow, days_overflow = 0, years_overflow = 0; - var days_in_month; + var daysInMonth; if (!this.isDate) { second = this.second + aExtraSeconds; @@ -3212,8 +3392,8 @@ ICAL.design = { day = this.day + aExtraDays + days_overflow; if (day > 0) { for (;;) { - var days_in_month = ICAL.icaltime.days_in_month(this.month, this.year); - if (day <= days_in_month) { + var daysInMonth = ICAL.icaltime.daysInMonth(this.month, this.year); + if (day <= daysInMonth) { break; } @@ -3223,7 +3403,7 @@ ICAL.design = { this.month = 1; } - day -= days_in_month; + day -= daysInMonth; } } else { while (day <= 0) { @@ -3234,7 +3414,7 @@ ICAL.design = { this.month--; } - day += ICAL.icaltime.days_in_month(this.month, this.year); + day += ICAL.icaltime.daysInMonth(this.month, this.year); } } @@ -3252,7 +3432,52 @@ ICAL.design = { toUnixTime: function toUnixTime() { var dur = this.subtractDate(ICAL.icaltime.epoch_time); return dur.toSeconds(); + }, + + /** + * Converts time to into Object + * which can be serialized then re-created + * using the constructor. + * + * Example: + * + * // toJSON will automatically be called + * var json = JSON.stringify(mytime); + * + * var deserialized = JSON.parse(json); + * + * var time = new ICAL.icaltime(deserialized); + * + */ + toJSON: function() { + var copy = [ + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'isDate' + ]; + + var result = Object.create(null); + + var i = 0; + var len = copy.length; + var prop; + + for (; i < len; i++) { + prop = copy[i]; + result[prop] = this[prop]; + } + + if (this.zone) { + result.timezone = this.zone.tzid; + } + + return result; } + }; (function setupNormalizeAttributes() { @@ -3291,13 +3516,13 @@ ICAL.design = { } })(); - ICAL.icaltime.days_in_month = function icaltime_days_in_month(month, year) { - var _days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + ICAL.icaltime.daysInMonth = function icaltime_daysInMonth(month, year) { + var _daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; var days = 30; if (month < 1 || month > 12) return days; - days = _days_in_month[month]; + days = _daysInMonth[month]; if (month == 2) { days += ICAL.icaltime.is_leap_year(year); @@ -3314,7 +3539,7 @@ ICAL.design = { } }; - ICAL.icaltime.from_day_of_year = function icaltime_from_day_of_year(aDayOfYear, aYear) { + ICAL.icaltime.fromDayOfYear = function icaltime_fromDayOfYear(aDayOfYear, aYear) { var year = aYear; var doy = aDayOfYear; var tt = new ICAL.icaltime(); @@ -3373,7 +3598,7 @@ ICAL.design = { isDate: true }); - var fourth_dow = t.day_of_week(); + var fourth_dow = t.dayOfWeek(); t.day += (1 - fourth_dow) + ((aWeekStart || ICAL.icaltime.SUNDAY) - 1); return t; }; @@ -3417,6 +3642,17 @@ ICAL.design = { (typeof(ICAL) === 'undefined')? ICAL = {} : ''; (function() { + + var DOW_MAP = { + SU: 1, + MO: 2, + TU: 3, + WE: 4, + TH: 5, + FR: 6, + SA: 7 + }; + ICAL.icalrecur = function icalrecur(data) { this.wrappedJSObject = this; this.parts = {}; @@ -3436,7 +3672,10 @@ ICAL.design = { icaltype: "RECUR", iterator: function(aStart) { - return new icalrecur_iterator(this, aStart); + return new ICAL.icalrecur_iterator({ + rule: this, + dtstart: aStart + }); }, clone: function clone() { @@ -3444,12 +3683,12 @@ ICAL.design = { //return ICAL.icalrecur.fromIcalProperty(this.toIcalProperty()); }, - is_finite: function isfinite() { - return (this.count || this.until); + isFinite: function isfinite() { + return !!(this.count || this.until); }, - is_by_count: function isbycount() { - return (this.count && !this.until); + isByCount: function isbycount() { + return !!(this.count && !this.until); }, addComponent: function addPart(aType, aValue) { @@ -3489,8 +3728,37 @@ ICAL.design = { return next; }, + toJSON: function() { + //XXX: extract this list up to proto? + var propsToCopy = [ + "freq", + "count", + "until", + "wkst", + "interval", + "parts" + ]; + + var result = Object.create(null); + + var i = 0; + var len = propsToCopy.length; + var prop; + + for (; i < len; i++) { + var prop = propsToCopy[i]; + result[prop] = this[prop]; + } + + if (result.until instanceof ICAL.icaltime) { + result.until = result.until.toJSON(); + } + + return result; + }, + fromData: function fromData(aData) { - var propsToCopy = ["freq", "count", "wkst", "interval"]; + var propsToCopy = ["freq", "count", "until", "wkst", "interval"]; for (var key in propsToCopy) { var prop = propsToCopy[key]; if (aData && prop.toUpperCase() in aData) { @@ -3502,8 +3770,19 @@ ICAL.design = { } } - if (aData && "until" in aData && aData.until) { - this.until = aData.until.clone(); + // wkst is usually in SU, etc.. format we need + // to convert it from the string + if (typeof(this.wkst) === 'string') { + this.wkst = ICAL.icalrecur.icalDayToNumericDay(this.wkst); + } + + // Another hack for multiple construction of until value. + if (this.until) { + if (this.until instanceof ICAL.icaltime) { + this.until = this.until.clone(); + } else { + this.until = ICAL.icaltime.fromData(this.until); + } } var partsToCopy = ["BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", @@ -3594,40 +3873,110 @@ ICAL.design = { return recur; }; - function icalrecur_iterator(aRule, aStart) { - this.rule = aRule; - this.dtstart = aStart; - this.by_data = ICAL.helpers.clone(aRule.parts, true); - this.days = []; - this.init(); + /** + * Convert an ical representation of a day (SU, MO, etc..) + * into a numeric value of that day. + * + * @param {String} day ical day. + * @return {Numeric} numeric value of given day. + */ + ICAL.icalrecur.icalDayToNumericDay = function toNumericDay(string) { + //XXX: this is here so we can deal + // with possibly invalid string values. + + return DOW_MAP[string]; + }; + +})(); +ICAL.icalrecur_iterator = (function() { + + /** + * Options: + * - rule: (ICAL.icalrecur) instance + * - dtstart: (ICAL.icaltime) start date of recurrence rule + * - initialized: (Boolean) when true will assume options + * are from previously constructed + * iterator and will not re-initialize + * iterator but resume its state from given data. + * + * - by_data: (for iterator de-serialization) + * - days: " + * - last: " + * - by_indices: " + */ + function icalrecur_iterator(options) { + this.fromData(options); } icalrecur_iterator.prototype = { + /** + * True when iteration is finished. + */ + completed: false, + rule: null, dtstart: null, last: null, occurrence_number: 0, by_indices: null, + initialized: false, by_data: null, days: null, days_index: 0, + fromData: function(options) { + this.rule = ICAL.helpers.formatClassType(options.rule, ICAL.icalrecur); + + if (!this.rule) { + throw new Error('iterator requires a (ICAL.icalrecur) rule'); + } + + this.dtstart = ICAL.helpers.formatClassType(options.dtstart, ICAL.icaltime); + + if (!this.dtstart) { + throw new Error('iterator requires a (ICAL.icaltime) dtstart'); + } + + if (options.by_data) { + this.by_data = options.by_data; + } else { + this.by_data = ICAL.helpers.clone(this.rule.parts, true); + } + + if (options.occurrence_number) + this.occurrence_number = options.occurrence_number; + + this.days = options.days || []; + this.last = ICAL.helpers.formatClassType(options.last, ICAL.icaltime); + + this.by_indices = options.by_indices; + + if (!this.by_indices) { + this.by_indices = { + "BYSECOND": 0, + "BYMINUTE": 0, + "BYHOUR": 0, + "BYDAY": 0, + "BYMONTH": 0, + "BYWEEKNO": 0, + "BYMONTHDAY": 0 + }; + } + + this.initialized = options.initialized || false; + + if (!this.initialized) { + this.init(); + } + }, + init: function icalrecur_iterator_init() { + this.initialized = true; this.last = this.dtstart.clone(); var parts = this.by_data; - this.by_indices = { - "BYSECOND": 0, - "BYMINUTE": 0, - "BYHOUR": 0, - "BYDAY": 0, - "BYMONTH": 0, - "BYWEEKNO": 0, - "BYMONTHDAY": 0 - }; - if ("BYDAY" in parts) { // libical does this earlier when the rule is loaded, but we postpone to // now so we can preserve the original order. @@ -3674,17 +4023,17 @@ ICAL.design = { if (this.rule.freq == "WEEKLY") { if ("BYDAY" in parts) { - var parts = this.rule_day_of_week(parts.BYDAY[0]); + var parts = this.ruleDayOfWeek(parts.BYDAY[0]); var pos = parts[0]; var rule_dow = parts[1]; - var dow = rule_dow - this.last.day_of_week(); - if ((this.last.day_of_week() < rule_dow && dow >= 0) || dow < 0) { + var dow = rule_dow - this.last.dayOfWeek(); + if ((this.last.dayOfWeek() < rule_dow && dow >= 0) || dow < 0) { // Initial time is after first day of BYDAY data this.last.day += dow; this.last.normalize(); } } else { - var wkMap = icalrecur_iterator._wkdayMap[this.dtstart.day_of_week()]; + var wkMap = icalrecur_iterator._wkdayMap[this.dtstart.dayOfWeek()]; parts.BYDAY = [wkMap]; } } @@ -3698,24 +4047,25 @@ ICAL.design = { this.increment_year(this.rule.interval); } - var next = ICAL.icaltime.from_day_of_year(this.days[0], this.last.year); + var next = ICAL.icaltime.fromDayOfYear(this.days[0], this.last.year); this.last.day = next.day; this.last.month = next.month; } if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) { + var coded_day = this.by_data.BYDAY[this.by_indices.BYDAY]; - var parts = this.rule_day_of_week(coded_day); + var parts = this.ruleDayOfWeek(coded_day); var pos = parts[0]; var dow = parts[1]; - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); var poscount = 0; if (pos >= 0) { - for (this.last.day = 1; this.last.day <= days_in_month; this.last.day++) { - if (this.last.day_of_week() == dow) { + for (this.last.day = 1; this.last.day <= daysInMonth; this.last.day++) { + if (this.last.dayOfWeek() == dow) { if (++poscount == pos || pos == 0) { break; } @@ -3723,8 +4073,8 @@ ICAL.design = { } } else { pos = -pos; - for (this.last.day = days_in_month; this.last.day != 0; this.last.day--) { - if (this.last.day_of_week() == dow) { + for (this.last.day = daysInMonth; this.last.day != 0; this.last.day--) { + if (this.last.dayOfWeek() == dow) { if (++poscount == pos) { break; } @@ -3732,14 +4082,23 @@ ICAL.design = { } } - if (this.last.day > days_in_month || this.last.day == 0) { + //XXX: This feels like a hack, but we need to initialize + // the BYMONTHDAY case correctly and byDayAndMonthDay handles + // this case. It accepts a special flag which will avoid incrementing + // the initial value without the flag days that match the start time + // would be missed. + if (this.has_by_data('BYMONTHDAY')) { + this._byDayAndMonthDay(true); + } + + if (this.last.day > daysInMonth || this.last.day == 0) { throw new Error("Malformed values in BYDAY part"); } } else if (this.has_by_data("BYMONTHDAY")) { if (this.last.day < 0) { - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); - this.last.day = days_in_month + this.last.day + 1; + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); + this.last.day = daysInMonth + this.last.day + 1; } this.last.normalize(); @@ -3751,6 +4110,11 @@ ICAL.design = { if ((this.rule.count && this.occurrence_number >= this.rule.count) || (this.rule.until && this.last.compare(this.rule.until) > 0)) { + + //XXX: right now this is just a flag and has no impact + // we can simplify the above case to check for completed later. + this.completed = true; + return null; } @@ -3776,7 +4140,6 @@ ICAL.design = { case "DAILY": this.next_day(); break; - case "WEEKLY": this.next_week(); break; @@ -3801,6 +4164,7 @@ ICAL.design = { } if (this.rule.until && this.last.compare(this.rule.until) > 0) { + this.completed = true; return null; } else { this.occurrence_number++; @@ -3886,51 +4250,195 @@ ICAL.design = { return end_of_data; }, - next_month: function next_month() { - var this_freq = (this.rule.freq == "MONTHLY"); - var data_valid = 1; + /** + * normalize each by day rule for a given year/month. + * Takes into account ordering and negative rules + * + * @param {Numeric} year current year. + * @param {Numeric} month current month. + * @param {Array} rules array of rules. + * + * @return {Array} sorted and normalized rules. + * Negative rules will be expanded to their + * correct positive values for easier processing. + */ + normalizeByMonthDayRules: function(year, month, rules) { + var daysInMonth = ICAL.icaltime.daysInMonth(month, year); + + // XXX: This is probably bad for performance to allocate + // a new array for each month we scan, if possible + // we should try to optimize this... + var newRules = []; + + var ruleIdx = 0; + var len = rules.length; + var rule; + + for (; ruleIdx < len; ruleIdx++) { + rule = rules[ruleIdx]; + + // if this rule falls outside of given + // month discard it. + if (Math.abs(rule) > daysInMonth) { + continue; + } + + // negative case + if (rule < 0) { + // we add (not subtract its a negative number) + // one from the rule because 1 === last day of month + rule = daysInMonth + (rule + 1); + } else if (rule === 0) { + // skip zero its invalid. + continue; + } + + // only add unique items... + if (newRules.indexOf(rule) === -1) { + newRules.push(rule); + } - if (this.next_hour() == 0) { - return data_valid; } - if (this.has_by_data("BYDAY") && this.has_by_data("BYMONTHDAY")) { - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); - var notFound = true; - var day; - - for (day = last.day + 1; notFound && day <= days_in_month; day++) { - for (var dayIdx = 0; dayIdx < this.by_data.BYDAY.length; dayIdx++) { - for (var mdIdx = 0; mdIdx < this.by_data.BYMONTHDAY.length; mdIdx++) { - var parts = this.rule_day_of_week(this.by_data.BYDAY[dayIdx]); - var pos = parts[0]; - var dow = parts[1]; - var mday = this.by_data.BYMONTHDAY[mdIdx]; + // unique and sort + return newRules.sort(); + }, - this.last.day = day; - var this_dow = this.last.day_of_week(); + /** + * NOTES: + * We are given a list of dates in the month (BYMONTHDAY) (23, etc..) + * Also we are given a list of days (BYDAY) (MO, 2SU, etc..) when + * both conditions match a given date (this.last.day) iteration stops. + * + * @param {Boolean} [isInit] when given true will not + * increment the current day (this.last). + */ + _byDayAndMonthDay: function(isInit) { + var byMonthDay; // setup in initMonth + var byDay = this.by_data.BYDAY; - if ((pos == 0 && dow == this_dow && mday == day) || - (this.last.nth_weekday(dow, pos))) { - notFound = false; - } - } + var date; + var dateIdx = 0; + var dateLen; // setup in initMonth + var dayIdx = 0; + var dayLen = byDay.length; + + // we are not valid by default + var dataIsValid = 0; + + var daysInMonth; + var self = this; + + function initMonth() { + daysInMonth = ICAL.icaltime.daysInMonth( + self.last.month, self.last.year + ); + + byMonthDay = self.normalizeByMonthDayRules( + self.last.year, + self.last.month, + self.by_data.BYMONTHDAY + ); + + dateLen = byMonthDay.length; + } + + function nextMonth() { + self.last.day = 1; + self.increment_month(); + initMonth(); + + dateIdx = 0; + dayIdx = 0; + } + + initMonth(); + + // should come after initMonth + if (isInit) { + this.last.day -= 1; + } + + while (!dataIsValid) { + // find next date + var next = byMonthDay[dateIdx++]; + + // increment the current date. This is really + // important otherwise we may fall into the infinite + // loop trap. The initial date takes care of the case + // where the current date is the date we are looking + // for. + date = this.last.day + 1; + + if (date > daysInMonth) { + nextMonth(); + continue; + } + + // after verify that the next date + // is in the current month we can increment + // it permanently. + this.last.day = date; + + // this logic is dependant on the BYMONTHDAYS + // being in order (which is done by #normalizeByMonthDayRules) + if (next >= this.last.day) { + // if the next month day is in the future jump to it. + this.last.day = next; + } else { + // in this case the 'next' monthday has past + // we must move to the month. + nextMonth(); + continue; + } + + // Now we can loop through the day rules to see + // if one matches the current month date. + for (dayIdx = 0; dayIdx < dayLen; dayIdx++) { + var parts = this.ruleDayOfWeek(byDay[dayIdx]); + var pos = parts[0]; + var dow = parts[1]; + + if (this.last.isNthWeekDay(dow, pos)) { + // when we find the valid one we can mark + // the conditions as met and break the loop. + // (Because we have this condition above + // it will also break the parent loop). + dataIsValid = 1; + break; } } - if (day > days_in_month) { - this.last.day = 1; - this.increment_month(); - this.last.day--; - data_valid = 0; + + // Its completely possible that the combination + // cannot be matched in the current month. + // When we reach the end of possible combinations + // in the current month we iterate to the next one. + if (!dataIsValid && dateIdx === (dateLen - 1)) { + nextMonth(); + continue; } + } + + return dataIsValid; + }, + next_month: function next_month() { + var this_freq = (this.rule.freq == "MONTHLY"); + var data_valid = 1; + + if (this.next_hour() == 0) { + return data_valid; + } + + if (this.has_by_data("BYDAY") && this.has_by_data("BYMONTHDAY")) { + data_valid = this._byDayAndMonthDay(); } else if (this.has_by_data("BYDAY")) { - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); var setpos = 0; if (this.has_by_data("BYSETPOS")) { - var lastday = this.last.day; - for (var day = 1; day <= days_in_month; day++) { + var last_day = this.last.day; + for (var day = 1; day <= daysInMonth; day++) { this.last.day = day; if (this.is_day_in_byday(this.last) && day <= last_day) { setpos++; @@ -3939,22 +4447,21 @@ ICAL.design = { this.last.day = last_day; } - for (var day = this.last.day + 1; day <= days_in_month; day++) { + for (var day = this.last.day + 1; day <= daysInMonth; day++) { this.last.day = day; if (this.is_day_in_byday(this.last)) { if (!this.has_by_data("BYSETPOS") || this.check_set_position(++setpos) || this.check_set_position(setpos - this.by_data.BYSETPOS.length - 1)) { - found = 1; + + data_valid = 1; break; } } } - data_valid = found; - - if (day > days_in_month) { + if (day > daysInMonth) { this.last.day = 1; this.increment_month(); @@ -3974,15 +4481,15 @@ ICAL.design = { this.increment_month(); } - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); var day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY]; if (day < 0) { - day = days_in_month + day + 1; + day = daysInMonth + day + 1; } - if (day > days_in_month) { + if (day > daysInMonth) { this.last.day = 1; data_valid = this.is_day_in_byday(this.last); } @@ -3991,8 +4498,8 @@ ICAL.design = { } else { this.last.day = this.by_data.BYMONTHDAY[0]; this.increment_month(); - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); - this.last.day = Math.min(this.last.day, days_in_month); + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); + this.last.day = Math.min(this.last.day, daysInMonth); } return data_valid; @@ -4020,7 +4527,7 @@ ICAL.design = { } var coded_day = this.by_data.BYDAY[this.by_indices.BYDAY]; - var parts = this.rule_day_of_week(coded_day); + var parts = this.ruleDayOfWeek(coded_day); var dow = parts[1]; dow -= this.rule.wkst; @@ -4032,16 +4539,16 @@ ICAL.design = { tt.month = this.last.month; tt.day = this.last.day; - var start_of_week = tt.start_doy_week(this.rule.wkst); + var startOfWeek = tt.start_doy_week(this.rule.wkst); - if (dow + start_of_week < 1) { + if (dow + startOfWeek < 1) { // The selected date is in the previous year if (!end_of_data) { continue; } } - var next = ICAL.icaltime.from_day_of_year(start_of_week + dow, + var next = ICAL.icaltime.fromDayOfYear(startOfWeek + dow, this.last.year); this.last.day = next.day; @@ -4066,7 +4573,7 @@ ICAL.design = { } while (this.days.length == 0); } - var next = ICAL.icaltime.from_day_of_year(this.days[this.days_index], + var next = ICAL.icaltime.fromDayOfYear(this.days[this.days_index], this.last.year); this.last.day = next.day; @@ -4075,19 +4582,12 @@ ICAL.design = { return 1; }, - rule_day_of_week: function rule_day_of_week(dow) { - var dowMap = { - SU: 1, - MO: 2, - TU: 3, - WE: 4, - TH: 5, - FR: 6, - SA: 7 - }; + ruleDayOfWeek: function ruleDayOfWeek(dow) { var matches = dow.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/); if (matches) { - return [parseInt(matches[1] || 0, 10), dowMap[matches[2]]] || 0; + var pos = parseInt(matches[1] || 0, 10); + dow = ICAL.icalrecur.icalDayToNumericDay(matches[2]); + return [pos, dow]; } else { return [0, 0]; } @@ -4126,11 +4626,11 @@ ICAL.design = { increment_monthday: function increment_monthday(inc) { for (var i = 0; i < inc; i++) { - var days_in_month = ICAL.icaltime.days_in_month(this.last.month, this.last.year); + var daysInMonth = ICAL.icaltime.daysInMonth(this.last.month, this.last.year); this.last.day++; - if (this.last.day > days_in_month) { - this.last.day -= days_in_month; + if (this.last.day > daysInMonth) { + this.last.day -= daysInMonth; this.increment_month(); } } @@ -4208,7 +4708,7 @@ ICAL.design = { t.month = month; t.day = 1; var first_week = t.week_number(this.rule.wkst); - t.day = ICAL.icaltime.days_in_month(month, aYear); + t.day = ICAL.icaltime.daysInMonth(month, aYear); var last_week = t.week_number(this.rule.wkst); for (monthIdx = first_week; monthIdx < last_week; monthIdx++) { validWeeks[monthIdx] = 1; @@ -4236,14 +4736,14 @@ ICAL.design = { if (partCount == 0) { var t = this.dtstart.clone(); t.year = this.last.year; - this.days.push(t.day_of_year()); + this.days.push(t.dayOfYear()); } else if (partCount == 1 && "BYMONTH" in parts) { for (var monthkey in this.by_data.BYMONTH) { var t2 = this.dtstart.clone(); t2.year = aYear; t2.month = this.by_data.BYMONTH[monthkey]; t2.isDate = true; - this.days.push(t2.day_of_year()); + this.days.push(t2.dayOfYear()); } } else if (partCount == 1 && "BYMONTHDAY" in parts) { for (var monthdaykey in this.by_data.BYMONTHDAY) { @@ -4251,7 +4751,7 @@ ICAL.design = { t2.day = this.by_data.BYMONTHDAY[monthdaykey]; t2.year = aYear; t2.isDate = true; - this.days.push(t2.day_of_year()); + this.days.push(t2.dayOfYear()); } } else if (partCount == 2 && "BYMONTHDAY" in parts && @@ -4263,7 +4763,7 @@ ICAL.design = { t.year = aYear; t.isDate = true; - this.days.push(t.day_of_year()); + this.days.push(t.dayOfYear()); } } } else if (partCount == 1 && "BYWEEKNO" in parts) { @@ -4276,23 +4776,24 @@ ICAL.design = { this.days = this.days.concat(this.expand_by_day(aYear)); } else if (partCount == 2 && "BYDAY" in parts && "BYMONTH" in parts) { for (var monthkey in this.by_data.BYMONTH) { - var days_in_month = ICAL.icaltime.days_in_month(month, aYear); + month = this.by_data.BYMONTH[monthkey]; + var daysInMonth = ICAL.icaltime.daysInMonth(month, aYear); t.year = aYear; t.month = this.by_data.BYMONTH[monthkey]; t.day = 1; t.isDate = true; - var first_dow = t.day_of_week(); - var doy_offset = t.day_of_year() - 1; + var first_dow = t.dayOfWeek(); + var doy_offset = t.dayOfYear() - 1; - t.day = days_in_month; - var last_dow = t.day_of_week(); + t.day = daysInMonth; + var last_dow = t.dayOfWeek(); if (this.has_by_data("BYSETPOS")) { var set_pos_counter = 0; var by_month_day = []; - for (var day = 1; day <= days_in_month; day++) { + for (var day = 1; day <= daysInMonth; day++) { t.day = day; if (this.is_day_in_byday(t)) { by_month_day.push(day); @@ -4307,22 +4808,25 @@ ICAL.design = { } } else { for (var daycodedkey in this.by_data.BYDAY) { + //TODO: This should return dates in order of occurrence + // (1,2,3, etc...) instead of by weekday (su, mo, etc..) var coded_day = this.by_data.BYDAY[daycodedkey]; - var parts = this.rule_day_of_week(coded_day); - var dow = parts[0]; - var pos = parts[1]; + var parts = this.ruleDayOfWeek(coded_day); + var pos = parts[0]; + var dow = parts[1]; + var month_day; var first_matching_day = ((dow + 7 - first_dow) % 7) + 1; - var last_matching_day = days_in_month - ((last_dow + 7 - dow) % 7); + var last_matching_day = daysInMonth - ((last_dow + 7 - dow) % 7); if (pos == 0) { - for (var day = first_matching_day; day <= days_in_month; day += 7) { + for (var day = first_matching_day; day <= daysInMonth; day += 7) { this.days.push(doy_offset + day); } } else if (pos > 0) { month_day = first_matching_day + (pos - 1) * 7; - if (month_day <= days_in_month) { + if (month_day <= daysInMonth) { this.days.push(doy_offset + month_day); } } else { @@ -4340,7 +4844,7 @@ ICAL.design = { for (var daykey in expandedDays) { var day = expandedDays[daykey]; - var tt = ICAL.icaltime.from_day_of_year(day, aYear); + var tt = ICAL.icaltime.fromDayOfYear(day, aYear); if (this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) { this.days.push(day); } @@ -4353,7 +4857,7 @@ ICAL.design = { for (var daykey in expandedDays) { var day = expandedDays[daykey]; - var tt = ICAL.icaltime.from_day_of_year(day, aYear); + var tt = ICAL.icaltime.fromDayOfYear(day, aYear); if (this.by_data.BYMONTH.indexOf(tt.month) >= 0 && this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) { @@ -4365,7 +4869,7 @@ ICAL.design = { for (var daykey in expandedDays) { var day = expandedDays[daykey]; - var tt = ICAL.icaltime.from_day_of_year(day, aYear); + var tt = ICAL.icaltime.fromDayOfYear(day, aYear); var weekno = tt.week_number(this.rule.wkst); if (this.by_data.BYWEEKNO.indexOf(weekno)) { @@ -4395,18 +4899,18 @@ ICAL.design = { tmp.day = 1; tmp.isDate = true; - var start_dow = tmp.day_of_week(); + var start_dow = tmp.dayOfWeek(); tmp.month = 12; tmp.day = 31; tmp.isDate = true; - var end_dow = tmp.day_of_week(); - var end_year_day = tmp.day_of_year(); + var end_dow = tmp.dayOfWeek(); + var end_year_day = tmp.dayOfYear(); for (var daykey in this.by_data.BYDAY) { var day = this.by_data.BYDAY[daykey]; - var parts = this.rule_day_of_week(day); + var parts = this.ruleDayOfWeek(day); var pos = parts[0]; var dow = parts[1]; @@ -4445,13 +4949,13 @@ ICAL.design = { is_day_in_byday: function is_day_in_byday(tt) { for (var daykey in this.by_data.BYDAY) { var day = this.by_data.BYDAY[daykey]; - var parts = this.rule_day_of_week(day); + var parts = this.ruleDayOfWeek(day); var pos = parts[0]; var dow = parts[1]; - var this_dow = tt.day_of_week(); + var this_dow = tt.dayOfWeek(); if ((pos == 0 && dow == this_dow) || - (tt.nth_weekday(dow, pos) == tt.day)) { + (tt.nthWeekDay(dow, pos) == tt.day)) { return 1; } } @@ -4459,16 +4963,27 @@ ICAL.design = { return 0; }, + /** + * Checks if given value is in BYSETPOS. + * + * @param {Numeric} aPos position to check for. + * @return {Boolean} false unless BYSETPOS rules exist + * and the given value is present in rules. + */ check_set_position: function check_set_position(aPos) { - return ("BYSETPOS" in this.by_data && - this.by_data.BYSETPOS.indexOf(aPos)); + if (this.has_by_data('BYSETPOS')) { + var idx = this.by_data.BYSETPOS.indexOf(aPos); + // negative numbers are not false-y + return idx !== -1; + } + return false; }, sort_byday_rules: function icalrecur_sort_byday_rules(aRules, aWeekStart) { for (var i = 0; i < aRules.length; i++) { for (var j = 0; j < i; j++) { - var one = this.rule_day_of_week(aRules[j])[1]; - var two = this.rule_day_of_week(aRules[i])[1]; + var one = this.ruleDayOfWeek(aRules[j])[1]; + var two = this.ruleDayOfWeek(aRules[i])[1]; one -= aWeekStart; two -= aWeekStart; if (one < 0) one += 7; @@ -4490,8 +5005,11 @@ ICAL.design = { if (aRuleType in this.by_data && ruleMapValue == icalrecur_iterator.CONTRACT) { - for (var bydatakey in this.by_data[aRuleType]) { - if (this.by_data[aRuleType][bydatakey] == v) { + + var ruleType = this.by_data[aRuleType]; + + for (var bydatakey in ruleType) { + if (ruleType[bydatakey] == v) { pass = true; break; } @@ -4504,14 +5022,14 @@ ICAL.design = { }, check_contracting_rules: function check_contracting_rules() { - var dow = this.last.day_of_week(); + var dow = this.last.dayOfWeek(); var weekNo = this.last.week_number(this.rule.wkst); - var doy = this.last.day_of_year(); + var doy = this.last.dayOfYear(); return (this.check_contract_restriction("BYSECOND", this.last.second) && this.check_contract_restriction("BYMINUTE", this.last.minute) && this.check_contract_restriction("BYHOUR", this.last.hour) && - this.check_contract_restriction("BYDAY", dow) && + this.check_contract_restriction("BYDAY", icalrecur_iterator._wkdayMap[dow]) && this.check_contract_restriction("BYWEEKNO", weekNo) && this.check_contract_restriction("BYMONTHDAY", this.last.day) && this.check_contract_restriction("BYMONTH", this.last.month) && @@ -4531,7 +5049,28 @@ ICAL.design = { } } return deftime; + }, + + /** + * Convert iterator into a serialize-able object. + * Will preserve current iteration sequence to ensure + * the seamless continuation of the recurrence rule. + */ + toJSON: function() { + var result = Object.create(null); + + result.initialized = this.initialized; + result.rule = this.rule.toJSON(); + result.dtstart = this.dtstart.toJSON(); + result.by_data = this.by_data; + result.days = this.days; + result.last = this.last.toJSON(); + result.by_indices = this.by_indices; + result.occurrence_number = this.occurrence_number; + + return result; } + }; icalrecur_iterator._wkdayMap = ["", "SU", "MO", "TU", "WE", "TH", "FR", "SA"]; @@ -4561,7 +5100,831 @@ ICAL.design = { icalrecur_iterator.CONTRACT = 1; icalrecur_iterator.EXPAND = 2; icalrecur_iterator.ILLEGAL = 3; -})(); + + return icalrecur_iterator; + +}()); +ICAL.RecurExpansion = (function() { + function formatTime(item) { + return ICAL.helpers.formatClassType(item, ICAL.icaltime); + } + + function compareTime(a, b) { + return a.compare(b); + } + + function isRecurringComponent(comp) { + return comp.hasProperty('RDATE') || + comp.hasProperty('RRULE') || + comp.hasProperty('RECURRENCE-ID'); + } + + function propertyValue(prop) { + return prop.data.value[0]; + } + + /** + * Primary class for expanding recurring rules. + * Can take multiple RRULEs, RDATEs, EXDATE(s) + * and iterate (in order) over each next occurrence. + * + * Once initialized this class can also be serialized + * saved and continue iteration from the last point. + * + * NOTE: it is intended that this class is to be used + * with ICAL.Event which handles recurrence exceptions. + * + * Options: + * - startDate: (ICAL.icaltime) start time of event (required) + * - component: (ICAL.icalcomponent) component (required unless resuming) + * + * Examples: + * + * // assuming event is a parsed ical component + * var event; + * + * var expand = new ICAL.RecurExpansion({ + * component: event, + * start: event.getFirstPropertyValue('DTSTART') + * }); + * + * // remember there are infinite rules + * // so its a good idea to limit the scope + * // of the iterations then resume later on. + * + * // next is always an ICAL.icaltime or null + * var next; + * + * while(someCondition && (next = expand.next())) { + * // do something with next + * } + * + * // save instance for later + * var json = JSON.stringify(expand); + * + * //... + * + * // NOTE: if the component's properties have + * // changed you will need to rebuild the + * // class and start over. This only works + * // when the component's recurrence info is the same. + * var expand = new ICAL.RecurExpansion(JSON.parse(json)); + * + * + * @param {Object} options see options block. + */ + function RecurExpansion(options) { + this.ruleDates = []; + this.exDates = []; + this.fromData(options); + } + + RecurExpansion.prototype = { + + /** + * True when iteration is fully completed. + */ + complete: false, + + /** + * Array of RRULE iterators. + * + * @type Array[ICAL.icalrecur_iterator] + * @private + */ + ruleIterators: null, + + /** + * Array of RDATE instances. + * + * @type Array[ICAL.icaltime] + * @private + */ + ruleDates: null, + + /** + * Array of EXDATE instances. + * + * @type Array[ICAL.icaltime] + * @private + */ + exDates: null, + + /** + * Current position in ruleDates array. + * @type Numeric + * @private + */ + ruleDateInc: 0, + + /** + * Current position in exDates array + * @type Numeric + * @private + */ + exDateInc: 0, + + /** + * Current negative date. + * + * @type ICAL.icaltime + * @private + */ + exDate: null, + + /** + * Current additional date. + * + * @type ICAL.icaltime + * @private + */ + ruleDate: null, + + /** + * Start date of recurring rules. + * + * @type ICAL.icaltime + */ + dtstart: null, + + /** + * Last expanded time + * + * @type ICAL.icaltime + */ + last: null, + + fromData: function(options) { + var start = ICAL.helpers.formatClassType(options.dtstart, ICAL.icaltime); + + if (!start) { + throw new Error('.dtstart (ICAL.icaltime) must be given'); + } else { + this.dtstart = start; + } + + if (options.component) { + this._init(options.component); + } else { + this.last = formatTime(options.last); + + this.ruleIterators = options.ruleIterators.map(function(item) { + return ICAL.helpers.formatClassType(item, ICAL.icalrecur_iterator); + }); + + this.ruleDateInc = options.ruleDateInc; + this.exDateInc = options.exDateInc; + + if (options.ruleDates) { + this.ruleDates = options.ruleDates.map(formatTime); + this.ruleDate = this.ruleDates[this.ruleDateInc]; + } + + if (options.exDates) { + this.exDates = options.exDates.map(formatTime); + this.exDate = this.exDates[this.exDateInc]; + } + + if (typeof(options.complete) !== 'undefined') { + this.complete = options.complete; + } + } + }, + + next: function() { + var iter; + var ruleOfDay; + var next; + var compare; + + var maxTries = 500; + var currentTry = 0; + + while (true) { + if (currentTry++ > maxTries) { + throw new Error( + 'max tries have occured, rule may be impossible to forfill.' + ); + } + + next = this.ruleDate; + iter = this._nextRecurrenceIter(this.last); + + // no more matches + // because we increment the rule day or rule + // _after_ we choose a value this should be + // the only spot where we need to worry about the + // end of events. + if (!next && !iter) { + // there are no more iterators or rdates + this.complete = true; + break; + } + + // no next rule day or recurrence rule is first. + if (!next || (iter && next.compare(iter.last) > 0)) { + // must be cloned, recur will reuse the time element. + next = iter.last.clone(); + // move to next so we can continue + iter.next(); + } + + // if the ruleDate is still next increment it. + if (this.ruleDate === next) { + this._nextRuleDay(); + } + + this.last = next; + + // check the negative rules + if (this.exDate) { + compare = this.exDate.compare(this.last); + + if (compare < 0) { + this._nextExDay(); + } + + // if the current rule is excluded skip it. + if (compare === 0) { + this._nextExDay(); + continue; + } + } + + //XXX: The spec states that after we resolve the final + // list of dates we execute EXDATE this seems somewhat counter + // intuitive to what I have seen most servers do so for now + // I exclude based on the original date not the one that may + // have been modified by the exception. + return this.last; + } + }, + + /** + * Converts object into a serialize-able format. + */ + toJSON: function() { + function toJSON(item) { + return item.toJSON(); + } + + var result = Object.create(null); + result.ruleIterators = this.ruleIterators.map(toJSON); + + if (this.ruleDates) { + result.ruleDates = this.ruleDates.map(toJSON); + } + + if (this.exDates) { + result.exDates = this.exDates.map(toJSON); + } + + result.ruleDateInc = this.ruleDateInc; + result.exDateInc = this.exDateInc; + result.last = this.last.toJSON(); + result.dtstart = this.dtstart.toJSON(); + result.complete = this.complete; + + return result; + }, + + + _extractDates: function(component, property) { + var result = []; + var props = component.getAllProperties(property); + var len = props.length; + var i = 0; + var prop; + + var idx; + + for (; i < len; i++) { + prop = propertyValue(props[i]); + + idx = ICAL.helpers.binsearchInsert( + result, + prop, + compareTime + ); + + // ordered insert + result.splice(idx, 0, prop); + } + + return result; + }, + + _init: function(component) { + this.ruleIterators = []; + + this.last = this.dtstart.clone(); + + if (component.hasProperty('RRULE')) { + var rules = component.getAllProperties('RRULE'); + var i = 0; + var len = rules.length; + + var rule; + var iter; + + for (; i < len; i++) { + rule = propertyValue(rules[i]); + rule = new ICAL.icalrecur(rule); + iter = rule.iterator(this.dtstart); + this.ruleIterators.push(iter); + + // increment to the next occurrence so future + // calls to next return times beyond the initial iteration. + // XXX: I find this suspicious might be a bug? + iter.next(); + } + } + + if (component.hasProperty('RDATE')) { + this.ruleDates = this._extractDates(component, 'RDATE'); + this.ruleDateInc = ICAL.helpers.binsearchInsert( + this.ruleDates, + this.last, + compareTime + ); + + this.ruleDate = this.ruleDates[this.ruleDateInc]; + } + + if (component.hasProperty('EXDATE')) { + this.exDates = this._extractDates(component, 'EXDATE'); + // if we have a .last day we increment the index to beyond it. + this.exDateInc = ICAL.helpers.binsearchInsert( + this.exDates, + this.last, + compareTime + ); + + this.exDate = this.exDates[this.exDateInc]; + } + }, + + _nextExDay: function() { + this.exDate = this.exDates[++this.exDateInc]; + }, + + _nextRuleDay: function() { + this.ruleDate = this.ruleDates[++this.ruleDateInc]; + }, + + /** + * Find and return the recurrence rule with the most + * recent event and return it. + * + * @return {Object} iterator. + */ + _nextRecurrenceIter: function() { + var iters = this.ruleIterators; + + if (iters.length === 0) { + return null; + } + + var len = iters.length; + var iter; + var iterTime; + var iterIdx = 0; + var chosenIter; + + // loop through each iterator + for (; iterIdx < len; iterIdx++) { + iter = iters[iterIdx]; + iterTime = iter.last; + + // if iteration is complete + // then we must exclude it from + // the search and remove it. + if (iter.completed) { + len--; + if (iterIdx !== 0) { + iterIdx--; + } + iters.splice(iterIdx, 1); + continue; + } + + // find the most recent possible choice + if (!chosenIter || chosenIter.last.compare(iterTime) > 0) { + // that iterator is saved + chosenIter = iter; + } + } + + // the chosen iterator is returned but not mutated + // this iterator contains the most recent event. + return chosenIter; + } + + }; + + return RecurExpansion; + +}()); +ICAL.Event = (function() { + + function Event(component, options) { + if (!(component instanceof ICAL.icalcomponent)) { + options = component; + component = null; + } + + if (!component) { + this.component = new ICAL.icalcomponent({ + name: 'VEVENT' + }); + } else { + this.component = component; + } + + this.exceptions = Object.create(null); + + if (options && options.exceptions) { + options.exceptions.forEach(this.relateException, this); + } + } + + Event.prototype = { + + /** + * List of related event exceptions. + * + * @type Array[ICAL.Event] + */ + exceptions: null, + + /** + * Relates a given event exception to this object. + * If the given component does not share the UID of + * this event it cannot be related and will throw an + * exception. + * + * If this component is an exception it cannot have other + * exceptions related to it. + * + * @param {ICAL.icalcomponent|ICAL.Event} obj component or event. + */ + relateException: function(obj) { + if (this.isRecurrenceException()) { + throw new Error('cannot relate exception to exceptions'); + } + + if (obj instanceof ICAL.icalcomponent) { + obj = new ICAL.Event(obj); + } + + if (obj.uid !== this.uid) { + throw new Error('attempted to relate unrelated exception'); + } + + // we don't sort or manage exceptions directly + // here the recurrence expander handles that. + this.exceptions[obj.recurrenceId.toString()] = obj; + }, + + /** + * Returns the occurrence details based on its start time. + * If the occurrence has an exception will return the details + * for that exception. + * + * NOTE: this method is intend to be used in conjunction + * with the #iterator method. + * + * @param {ICAL.icaltime} occurrence time occurrence. + */ + getOccurrenceDetails: function(occurrence) { + var id = occurrence.toString(); + var result = { + //XXX: Clone? + recurrenceId: occurrence + }; + + if (id in this.exceptions) { + var item = result.item = this.exceptions[id]; + result.startDate = item.startDate; + result.endDate = item.endDate; + result.item = item; + } else { + var end = occurrence.clone(); + end.addDuration(this.duration); + + result.endDate = end; + result.startDate = occurrence; + result.item = this; + } + + return result; + }, + + /** + * Builds a recur expansion instance for a specific + * point in time (defaults to startDate). + * + * @return {ICAL.RecurExpansion} expander object. + */ + iterator: function(startTime) { + return new ICAL.RecurExpansion({ + component: this.component, + dtstart: startTime || this.startDate + }); + }, + + isRecurring: function() { + var comp = this.component; + return comp.hasProperty('RRULE') || comp.hasProperty('RDATE'); + }, + + isRecurrenceException: function() { + return this.component.hasProperty('RECURRENCE-ID'); + }, + + /** + * Returns the types of recurrences this event may have. + * + * Returned as an object with the following possible keys: + * + * - YEARLY + * - MONTHLY + * - WEEKLY + * - DAILY + * - MINUTELY + * - SECONDLY + * + * @return {Object} object of recurrence flags. + */ + getRecurrenceTypes: function() { + var rules = this.component.getAllProperties('RRULE'); + var i = 0; + var len = rules.length; + var result = Object.create(null); + + for (; i < len; i++) { + result[rules[i].data.FREQ] = true; + } + + return result; + }, + + get uid() { + return this._firstPropsValue('UID'); + }, + + set uid(value) { + this._setProp('UID', value); + }, + + get startDate() { + return this._firstProp('DTSTART'); + }, + + set startDate(value) { + this._setProp('DTSTART', value); + }, + + get endDate() { + return this._firstProp('DTEND'); + }, + + set endDate(value) { + this._setProp('DTEND', value); + }, + + get duration() { + // cached because its dynamically calculated + // and may be frequently used. This could be problematic + // later if we modify the underlying start/endDate. + // + // When do add that functionality it should expire this cache... + if (typeof(this._duration) === 'undefined') { + this._duration = this.endDate.subtractDate(this.startDate); + } + return this._duration; + }, + + get location() { + return this._firstPropsValue('LOCATION'); + }, + + set location(value) { + return this._setProp('LOCATION', value); + }, + + get attendees() { + //XXX: This is way lame we should have a better + // data structure for this later. + return this.component.getAllProperties('ATTENDEE'); + }, + + get summary() { + return this._firstPropsValue('SUMMARY'); + }, + + set summary(value) { + this._setProp('SUMMARY', value); + }, + + get description() { + return this._firstPropsValue('DESCRIPTION'); + }, + + set description(value) { + this._setProp('DESCRIPTION', value); + }, + + get organizer() { + return this._firstProp('ORGANIZER'); + }, + + set organizer(value) { + this._setProp('ORGANIZER', value); + }, + + get sequence() { + return this._firstPropsValue('SEQUENCE'); + }, + + set sequence(value) { + this._setProp('SEQUENCE', value); + }, + + get recurrenceId() { + return this._firstProp('RECURRENCE-ID'); + }, + + set recurrenceId(value) { + this._setProp('RECURRENCE-ID', value); + }, + + _setProp: function(name, value) { + this.component.updatePropertyWithValue(name, value); + }, + + _firstProp: function(name) { + return this.component.getFirstPropertyValue(name); + }, + + /** + * Return the first property value. + * Most useful in cases where no properties + * are expected and the value will be a text type. + */ + _firstPropsValue: function(name) { + var prop = this._firstProp(name); + + if (prop && prop.data && prop.data.value) { + return prop.data.value[0]; + } + + return null; + }, + + toString: function() { + return this.component.toString(); + } + + }; + + return Event; + +}()); +ICAL.ComponentParser = (function() { + + /** + * Component parser initializer. + * + * Usage: + * + * var options = { + * // when false no events will be emitted for type + * parseEvent: true, + * parseTimezone: true + * }; + * + * var parser = new ICAL.ComponentParser(options); + * + * parser.onevent() { + * //... + * } + * + * // ontimezone, etc... + * + * parser.oncomplete = function() { + * + * }; + * + * parser.process(string | component); + * + * + * @param {Object} options component parser options. + */ + function ComponentParser(options) { + if (typeof(options) === 'undefined') { + options = {}; + } + + var key; + for (key in options) { + if (options.hasOwnProperty(key)) { + this[key] = options[key]; + } + } + } + + ComponentParser.prototype = { + + /** + * When true parse events + * + * @type Boolean + */ + parseEvent: true, + + /** + * when true parse timezones + * + * @type Boolean + */ + parseTimezone: true, + + + /* SAX like events here for reference */ + + /** + * Fired when parsing is complete + */ + oncomplete: function() {}, + + /** + * Fired if an error occurs during parsing. + * + * @param {Error} err details of error. + */ + onerror: function(err) {}, + + /** + * Fired when a top level component (vtimezone) is found + * + * @param {ICAL.icaltimezone} timezone object. + */ + ontimezone: function(component) {}, + + /* + * Fired when a top level component (VEVENT) is found. + * @param {ICAL.Event} component top level component. + */ + onevent: function(component) {}, + + /** + * Process a string or parse ical object. + * This function itself will return nothing but + * will start the parsing process. + * + * Events must be registered prior to calling this method. + * + * @param {String|Object} ical string or parsed ical object. + */ + process: function(ical) { + //TODO: this is sync now in the future we will have a incremental parser. + if (typeof(ical) === 'string') { + ical = ICAL.parse(ical); + } + + if (!(ical instanceof ICAL.icalcomponent)) { + ical = new ICAL.icalcomponent(ical); + } + + var components = ical.getAllSubcomponents(); + var i = 0; + var len = components.length; + var component; + + for (; i < len; i++) { + component = components[i]; + + switch (component.name) { + case 'VEVENT': + if (this.parseEvent) { + this.onevent(new ICAL.Event(component)); + } + break; + default: + continue; + } + } + + //XXX: ideally we should do a "nextTick" here + // so in all cases this is actually async. + this.oncomplete(); + } + }; + + return ComponentParser; + +}()); /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. @@ -4611,3 +5974,4 @@ ICAL.design = { return state.currentData; }; }()); + |