summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/src/CalRecurrenceInfo.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--comm/calendar/base/src/CalRecurrenceInfo.jsm847
1 files changed, 847 insertions, 0 deletions
diff --git a/comm/calendar/base/src/CalRecurrenceInfo.jsm b/comm/calendar/base/src/CalRecurrenceInfo.jsm
new file mode 100644
index 0000000000..f24f210a30
--- /dev/null
+++ b/comm/calendar/base/src/CalRecurrenceInfo.jsm
@@ -0,0 +1,847 @@
+/* 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/. */
+
+var EXPORTED_SYMBOLS = ["CalRecurrenceInfo"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function getRidKey(date) {
+ if (!date) {
+ return null;
+ }
+ let timezone = date.timezone;
+ if (!timezone.isUTC && !timezone.isFloating) {
+ date = date.getInTimezone(cal.dtz.UTC);
+ }
+ return date.icalString;
+}
+
+/**
+ * Constructor for `calIRecurrenceInfo` objects.
+ *
+ * @class
+ * @implements {calIRecurrenceInfo}
+ * @param {calIItemBase} [item] - Optional calendar item for which this recurrence applies.
+ */
+function CalRecurrenceInfo(item) {
+ this.wrappedJSObject = this;
+ this.mRecurrenceItems = [];
+ this.mExceptionMap = {};
+ if (item) {
+ this.item = item;
+ }
+}
+
+CalRecurrenceInfo.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIRecurrenceInfo"]),
+ classID: Components.ID("{04027036-5884-4a30-b4af-f2cad79f6edf}"),
+
+ mImmutable: false,
+ mBaseItem: null,
+ mEndDate: null,
+ mRecurrenceItems: null,
+ mPositiveRules: null,
+ mNegativeRules: null,
+ mExceptionMap: null,
+
+ /**
+ * Helpers
+ */
+ ensureBaseItem() {
+ if (!this.mBaseItem) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ },
+ ensureMutable() {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+ ensureSortedRecurrenceRules() {
+ if (!this.mPositiveRules || !this.mNegativeRules) {
+ this.mPositiveRules = [];
+ this.mNegativeRules = [];
+ for (let ritem of this.mRecurrenceItems) {
+ if (ritem.isNegative) {
+ this.mNegativeRules.push(ritem);
+ } else {
+ this.mPositiveRules.push(ritem);
+ }
+ }
+ }
+ },
+
+ /**
+ * Mutability bits
+ */
+ get isMutable() {
+ return !this.mImmutable;
+ },
+ makeImmutable() {
+ if (this.mImmutable) {
+ return;
+ }
+
+ for (let ritem of this.mRecurrenceItems) {
+ if (ritem.isMutable) {
+ ritem.makeImmutable();
+ }
+ }
+
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ if (item.isMutable) {
+ item.makeImmutable();
+ }
+ }
+
+ this.mImmutable = true;
+ },
+
+ clone() {
+ let cloned = new CalRecurrenceInfo();
+ cloned.mBaseItem = this.mBaseItem;
+
+ let clonedItems = [];
+ for (let ritem of this.mRecurrenceItems) {
+ clonedItems.push(ritem.clone());
+ }
+ cloned.mRecurrenceItems = clonedItems;
+
+ let clonedExceptions = {};
+ for (let exitem in this.mExceptionMap) {
+ clonedExceptions[exitem] = this.mExceptionMap[exitem].cloneShallow(this.mBaseItem);
+ }
+ cloned.mExceptionMap = clonedExceptions;
+
+ return cloned;
+ },
+
+ /*
+ * calIRecurrenceInfo
+ */
+ get item() {
+ return this.mBaseItem;
+ },
+ set item(value) {
+ this.ensureMutable();
+
+ value = cal.unwrapInstance(value);
+ this.mBaseItem = value;
+ // patch exception's parentItem:
+ for (let ex in this.mExceptionMap) {
+ let exitem = this.mExceptionMap[ex];
+ exitem.parentItem = value;
+ }
+ },
+
+ get isFinite() {
+ this.ensureBaseItem();
+
+ for (let ritem of this.mRecurrenceItems) {
+ if (!ritem.isFinite) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Get the item ending date (end date for an event, due date or entry date if available for a task).
+ *
+ * @param {calIEvent | calITodo} item - The item.
+ * @returns {calIDateTime | null} The ending date or null.
+ */
+ getItemEndingDate(item) {
+ if (item.isEvent()) {
+ if (item.endDate) {
+ return item.endDate;
+ }
+ } else if (item.isTodo()) {
+ // Due date must be considered since it is used when displaying the task in agenda view.
+ if (item.dueDate) {
+ return item.dueDate;
+ } else if (item.entryDate) {
+ return item.entryDate;
+ }
+ }
+ return null;
+ },
+
+ get recurrenceEndDate() {
+ // The lowest and highest possible values of a PRTime (64-bit integer) when in javascript,
+ // which stores them as floating-point values.
+ const MIN_PRTIME = -9223372036854775000;
+ const MAX_PRTIME = 9223372036854775000;
+
+ // If this object is mutable, skip this optimisation, so that we don't have to work out every
+ // possible modification and invalidate the cached value. Immutable objects are unlikely to
+ // exist for long enough to really benefit anyway.
+ if (this.isMutable) {
+ return MAX_PRTIME;
+ }
+
+ if (this.mEndDate === null) {
+ if (this.isFinite) {
+ this.mEndDate = MIN_PRTIME;
+ let lastOccurrence = this.getPreviousOccurrence(cal.createDateTime("99991231T235959Z"));
+ if (lastOccurrence) {
+ let endingDate = this.getItemEndingDate(lastOccurrence);
+ if (endingDate) {
+ this.mEndDate = endingDate.nativeTime;
+ }
+ }
+
+ // A modified occurrence may have a new ending date positioned after last occurrence one.
+ for (let rid in this.mExceptionMap) {
+ let item = this.mExceptionMap[rid];
+
+ let endingDate = this.getItemEndingDate(item);
+ if (endingDate && this.mEndDate < endingDate.nativeTime) {
+ this.mEndDate = endingDate.nativeTime;
+ }
+ }
+ } else {
+ this.mEndDate = MAX_PRTIME;
+ }
+ }
+
+ return this.mEndDate;
+ },
+
+ getRecurrenceItems() {
+ this.ensureBaseItem();
+
+ return this.mRecurrenceItems;
+ },
+
+ setRecurrenceItems(aItems) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ // XXX should we clone these?
+ this.mRecurrenceItems = aItems;
+ this.mPositiveRules = null;
+ this.mNegativeRules = null;
+ },
+
+ countRecurrenceItems() {
+ this.ensureBaseItem();
+
+ return this.mRecurrenceItems.length;
+ },
+
+ getRecurrenceItemAt(aIndex) {
+ this.ensureBaseItem();
+
+ if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return this.mRecurrenceItems[aIndex];
+ },
+
+ appendRecurrenceItem(aItem) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ aItem = cal.unwrapInstance(aItem);
+ this.mRecurrenceItems.push(aItem);
+ if (aItem.isNegative) {
+ this.mNegativeRules.push(aItem);
+ } else {
+ this.mPositiveRules.push(aItem);
+ }
+ },
+
+ deleteRecurrenceItemAt(aIndex) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (this.mRecurrenceItems[aIndex].isNegative) {
+ this.mNegativeRules = null;
+ } else {
+ this.mPositiveRules = null;
+ }
+
+ this.mRecurrenceItems.splice(aIndex, 1);
+ },
+
+ deleteRecurrenceItem(aItem) {
+ aItem = cal.unwrapInstance(aItem);
+ let pos = this.mRecurrenceItems.indexOf(aItem);
+ if (pos > -1) {
+ this.deleteRecurrenceItemAt(pos);
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ },
+
+ insertRecurrenceItemAt(aItem, aIndex) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ if (aIndex < 0 || aIndex > this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ aItem = cal.unwrapInstance(aItem);
+ if (aItem.isNegative) {
+ this.mNegativeRules.push(aItem);
+ } else {
+ this.mPositiveRules.push(aItem);
+ }
+
+ this.mRecurrenceItems.splice(aIndex, 0, aItem);
+ },
+
+ clearRecurrenceItems() {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ this.mRecurrenceItems = [];
+ this.mPositiveRules = [];
+ this.mNegativeRules = [];
+ },
+
+ /*
+ * calculations
+ */
+ getNextOccurrence(aTime) {
+ this.ensureBaseItem();
+ this.ensureSortedRecurrenceRules();
+
+ let startDate = this.mBaseItem.recurrenceStartDate;
+ let nextOccurrences = [];
+ let invalidOccurrences;
+ let negMap = {};
+ let minOccRid;
+
+ // Go through all negative rules to create a map of occurrences that
+ // should be skipped when going through occurrences.
+ for (let ritem of this.mNegativeRules) {
+ // TODO Infinite rules (i.e EXRULE) are not taken into account,
+ // because its very performance hungry and could potentially
+ // lead to a deadlock (i.e RRULE is canceled out by an EXRULE).
+ // This is ok for now, since EXRULE is deprecated anyway.
+ if (ritem.isFinite) {
+ // Get all occurrences starting at our recurrence start date.
+ // This is fine, since there will never be an EXDATE that
+ // occurs before the event started and its illegal to EXDATE an
+ // RDATE.
+ let rdates = ritem.getOccurrences(startDate, startDate, null, 0);
+ // Map all negative dates.
+ for (let rdate of rdates) {
+ negMap[getRidKey(rdate)] = true;
+ }
+ } else {
+ cal.WARN(
+ "Item '" +
+ this.mBaseItem.title +
+ "'" +
+ (this.mBaseItem.calendar ? " (" + this.mBaseItem.calendar.name + ")" : "") +
+ " has an infinite negative rule (EXRULE)"
+ );
+ }
+ }
+
+ let bailCounter = 0;
+ do {
+ invalidOccurrences = 0;
+ // Go through all positive rules and get the next recurrence id
+ // according to that rule. If for all rules the rid is "invalid",
+ // (i.e an EXDATE removed it, or an exception moved it somewhere
+ // else), then get the respective next rid.
+ //
+ // If in a loop at least one rid is valid (i.e not an exception, not
+ // an exdate, is after aTime), then remember the lowest one.
+ for (let i = 0; i < this.mPositiveRules.length; i++) {
+ let rDateInstance = cal.wrapInstance(this.mPositiveRules[i], Ci.calIRecurrenceDate);
+ let rRuleInstance = cal.wrapInstance(this.mPositiveRules[i], Ci.calIRecurrenceRule);
+ if (rDateInstance) {
+ // RDATEs are special. there is only one date in this rule,
+ // so no need to search anything.
+ let rdate = rDateInstance.date;
+ if (!nextOccurrences[i] && rdate.compare(aTime) > 0) {
+ // The RDATE falls into range, save it.
+ nextOccurrences[i] = rdate;
+ } else {
+ // The RDATE doesn't fall into range. This rule will
+ // always be invalid, since it can't give out a date.
+ nextOccurrences[i] = null;
+ invalidOccurrences++;
+ }
+ } else if (rRuleInstance) {
+ // RRULEs must not start searching before |startDate|, since
+ // the pattern is only valid afterwards. If an occurrence
+ // was found in a previous round, we can go ahead and start
+ // searching from that occurrence.
+ let searchStart = nextOccurrences[i] || startDate;
+
+ // Search for the next occurrence after aTime. If the last
+ // round was invalid, then in this round we need to search
+ // after nextOccurrences[i] to make sure getNextOccurrence()
+ // doesn't find the same occurrence again.
+ let searchDate =
+ nextOccurrences[i] && nextOccurrences[i].compare(aTime) > 0
+ ? nextOccurrences[i]
+ : aTime;
+
+ nextOccurrences[i] = rRuleInstance.getNextOccurrence(searchStart, searchDate);
+ }
+
+ // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME
+ let nextKey = getRidKey(nextOccurrences[i]);
+ let isInExceptionMap =
+ nextKey && (this.mExceptionMap[nextKey.substring(0, 8)] || this.mExceptionMap[nextKey]);
+ let isInNegMap = nextKey && (negMap[nextKey.substring(0, 8)] || negMap[nextKey]);
+ if (nextKey && (isInNegMap || isInExceptionMap)) {
+ // If the found recurrence id points to either an exception
+ // (will handle later) or an EXDATE, then nextOccurrences[i]
+ // is invalid and we might need to try again next round.
+ invalidOccurrences++;
+ } else if (nextOccurrences[i]) {
+ // We have a valid recurrence id (not an exception, not an
+ // EXDATE, falls into range). We only need to save the
+ // earliest occurrence after aTime (checking for aTime is
+ // not needed, since getNextOccurrence() above returns only
+ // occurrences after aTime).
+ if (!minOccRid || minOccRid.compare(nextOccurrences[i]) > 0) {
+ minOccRid = nextOccurrences[i];
+ }
+ }
+ }
+
+ // To make sure users don't just report bugs like "the application
+ // hangs", bail out after 100 runs. If this happens, it is most
+ // likely a bug.
+ if (bailCounter++ > 100) {
+ cal.ERROR("Could not find next occurrence after 100 runs!");
+ return null;
+ }
+
+ // We counted how many positive rules found out that their next
+ // candidate is invalid. If all rules produce invalid next
+ // occurrences, a second round is needed.
+ } while (invalidOccurrences == this.mPositiveRules.length);
+
+ // Since we need to compare occurrences by date, save the rid found
+ // above also as a date. This works out because above we skipped
+ // exceptions.
+ let minOccDate = minOccRid;
+
+ // Scan exceptions for any dates earlier than the above found
+ // minOccDate, but still after aTime.
+ for (let ex in this.mExceptionMap) {
+ let exc = this.mExceptionMap[ex];
+ let start = exc.recurrenceStartDate;
+ if (start.compare(aTime) > 0 && (!minOccDate || start.compare(minOccDate) <= 0)) {
+ // This exception is earlier, save its rid (for getting the
+ // occurrence later on) and its date (for comparing to other
+ // exceptions).
+ minOccRid = exc.recurrenceId;
+ minOccDate = start;
+ }
+ }
+
+ // If we found a recurrence id any time above, then return the
+ // occurrence for it.
+ return minOccRid ? this.getOccurrenceFor(minOccRid) : null;
+ },
+
+ getPreviousOccurrence(aTime) {
+ // HACK We never know how early an RDATE might be before the actual
+ // recurrence start. Since rangeStart cannot be null for recurrence
+ // items like calIRecurrenceRule, we need to work around by supplying a
+ // very early date. Again, this might have a high performance penalty.
+ let early = cal.createDateTime();
+ early.icalString = "00000101T000000Z";
+
+ let rids = this.calculateDates(early, aTime, 0);
+ // The returned dates are sorted, so the last one is a good
+ // candidate, if it exists.
+ return rids.length > 0 ? this.getOccurrenceFor(rids[rids.length - 1].id) : null;
+ },
+
+ // internal helper function;
+ calculateDates(aRangeStart, aRangeEnd, aMaxCount) {
+ this.ensureBaseItem();
+ this.ensureSortedRecurrenceRules();
+
+ // workaround for UTC- timezones
+ let rangeStart = cal.dtz.ensureDateTime(aRangeStart);
+ let rangeEnd = cal.dtz.ensureDateTime(aRangeEnd);
+
+ // If aRangeStart falls in the middle of an occurrence, libical will
+ // not return that occurrence when we go and ask for an
+ // icalrecur_iterator_new. This actually seems fairly rational, so
+ // instead of hacking libical, I'm going to move aRangeStart back far
+ // enough to make sure we get the occurrences we might miss.
+ let searchStart = rangeStart.clone();
+ let baseDuration = this.mBaseItem.duration;
+ if (baseDuration) {
+ let duration = baseDuration.clone();
+ duration.isNegative = true;
+ searchStart.addDuration(duration);
+ }
+
+ let startDate = this.mBaseItem.recurrenceStartDate;
+ if (startDate == null) {
+ // Todo created by other apps may have a saved recurrence but
+ // start and due dates disabled. Since no recurrenceStartDate,
+ // treat as undated task.
+ return [];
+ }
+
+ let dates = [];
+
+ // toss in exceptions first. Save a map of all exceptions ids, so we
+ // don't add the wrong occurrences later on.
+ let occurrenceMap = {};
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ let occDate = cal.item.checkIfInRange(item, aRangeStart, aRangeEnd, true);
+ occurrenceMap[ex] = true;
+ if (occDate) {
+ dates.push({ id: item.recurrenceId, rstart: occDate });
+ }
+ }
+
+ // DTSTART/DUE is always part of the (positive) expanded set:
+ // DTSTART always equals RECURRENCE-ID for items expanded from RRULE
+ let baseOccDate = cal.item.checkIfInRange(this.mBaseItem, aRangeStart, aRangeEnd, true);
+ let baseOccDateKey = getRidKey(baseOccDate);
+ if (baseOccDate && !occurrenceMap[baseOccDateKey]) {
+ occurrenceMap[baseOccDateKey] = true;
+ dates.push({ id: baseOccDate, rstart: baseOccDate });
+ }
+
+ // if both range start and end are specified, we ask for all of the occurrences,
+ // to make sure we catch all possible exceptions. If aRangeEnd isn't specified,
+ // then we have to ask for aMaxCount, and hope for the best.
+ let maxCount;
+ if (rangeStart && rangeEnd) {
+ maxCount = 0;
+ } else {
+ maxCount = aMaxCount;
+ }
+
+ // Apply positive rules
+ for (let ritem of this.mPositiveRules) {
+ let cur_dates = ritem.getOccurrences(startDate, searchStart, rangeEnd, maxCount);
+ if (cur_dates.length == 0) {
+ continue;
+ }
+
+ // if positive, we just add these date to the existing set,
+ // but only if they're not already there
+
+ let index = 0;
+ let len = cur_dates.length;
+
+ // skip items before rangeStart due to searchStart libical hack:
+ if (rangeStart && baseDuration) {
+ for (; index < len; ++index) {
+ let date = cur_dates[index].clone();
+ date.addDuration(baseDuration);
+ if (rangeStart.compare(date) < 0) {
+ break;
+ }
+ }
+ }
+ for (; index < len; ++index) {
+ let date = cur_dates[index];
+ let dateKey = getRidKey(date);
+ if (occurrenceMap[dateKey]) {
+ // Don't add occurrences twice (i.e exception was
+ // already added before)
+ continue;
+ }
+ dates.push({ id: date, rstart: date });
+ occurrenceMap[dateKey] = true;
+ }
+ }
+
+ dates.sort((a, b) => a.rstart.compare(b.rstart));
+
+ // Apply negative rules
+ for (let ritem of this.mNegativeRules) {
+ let cur_dates = ritem.getOccurrences(startDate, searchStart, rangeEnd, maxCount);
+ if (cur_dates.length == 0) {
+ continue;
+ }
+
+ // XXX: i'm pretty sure negative dates can't really have exceptions
+ // (like, you can't make a date "real" by defining an RECURRENCE-ID which
+ // is an EXDATE, and then giving it a real DTSTART) -- so we don't
+ // check exceptions here
+ for (let dateToRemove of cur_dates) {
+ let dateToRemoveKey = getRidKey(dateToRemove);
+ if (dateToRemove.isDate) {
+ // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME
+ let toRemove = [];
+ for (let occurenceKey in occurrenceMap) {
+ if (occurrenceMap[occurenceKey] && occurenceKey.substring(0, 8) == dateToRemoveKey) {
+ dates = dates.filter(date => date.id.compare(dateToRemove) != 0);
+ toRemove.push(occurenceKey);
+ }
+ }
+ for (let i = 0; i < toRemove.length; i++) {
+ delete occurrenceMap[toRemove[i]];
+ }
+ } else if (occurrenceMap[dateToRemoveKey]) {
+ // TODO PERF Theoretically we could use occurrence map
+ // to construct the array of occurrences. Right now I'm
+ // just using the occurrence map to skip the filter
+ // action if the occurrence isn't there anyway.
+ dates = dates.filter(date => date.id.compare(dateToRemove) != 0);
+ delete occurrenceMap[dateToRemoveKey];
+ }
+ }
+ }
+
+ // The list was already sorted above, chop anything over aMaxCount, if
+ // specified.
+ if (aMaxCount && dates.length > aMaxCount) {
+ dates = dates.slice(0, aMaxCount);
+ }
+
+ return dates;
+ },
+
+ getOccurrenceDates(aRangeStart, aRangeEnd, aMaxCount) {
+ let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount);
+ dates = dates.map(date => date.rstart);
+ return dates;
+ },
+
+ getOccurrences(aRangeStart, aRangeEnd, aMaxCount) {
+ let results = [];
+ let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount);
+ if (dates.length) {
+ let count;
+ if (aMaxCount) {
+ count = Math.min(aMaxCount, dates.length);
+ } else {
+ count = dates.length;
+ }
+
+ for (let i = 0; i < count; i++) {
+ results.push(this.getOccurrenceFor(dates[i].id));
+ }
+ }
+ return results;
+ },
+
+ getOccurrenceFor(aRecurrenceId) {
+ let proxy = this.getExceptionFor(aRecurrenceId);
+ if (!proxy) {
+ return this.item.createProxy(aRecurrenceId);
+ }
+ return proxy;
+ },
+
+ removeOccurrenceAt(aRecurrenceId) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ let rdate = cal.createRecurrenceDate();
+ rdate.isNegative = true;
+ rdate.date = aRecurrenceId.clone();
+
+ this.removeExceptionFor(rdate.date);
+
+ this.appendRecurrenceItem(rdate);
+ },
+
+ restoreOccurrenceAt(aRecurrenceId) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ for (let i = 0; i < this.mRecurrenceItems.length; i++) {
+ let rdate = cal.wrapInstance(this.mRecurrenceItems[i], Ci.calIRecurrenceDate);
+ if (rdate) {
+ if (rdate.isNegative && rdate.date.compare(aRecurrenceId) == 0) {
+ return this.deleteRecurrenceItemAt(i);
+ }
+ }
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ },
+
+ //
+ // exceptions
+ //
+
+ //
+ // Some notes:
+ //
+ // The way I read ICAL, RECURRENCE-ID is used to specify a
+ // particular instance of a recurring event, according to the
+ // RRULEs/RDATEs/etc. specified in the base event. If one of
+ // these is to be changed ("an exception"), then it can be
+ // referenced via the UID of the original event, and a
+ // RECURRENCE-ID of the start time of the instance to change.
+ // This, to me, means that an event where one of the instances has
+ // changed to a different time has a RECURRENCE-ID of the original
+ // start time, and a DTSTART/DTEND representing the new time.
+ //
+ // ITIP, however, seems to want something different -- you're
+ // supposed to use UID/RECURRENCE-ID to select from the current
+ // set of occurrences of an event. If you change the DTSTART for
+ // an instance, you're supposed to use the old (original) DTSTART
+ // as the RECURRENCE-ID, and put the new time as the DTSTART.
+ // However, after that change, to refer to that instance in the
+ // future, you have to use the modified DTSTART as the
+ // RECURRENCE-ID. This madness is described in ITIP end of
+ // section 3.7.1.
+ //
+ // This implementation does the first approach (RECURRENCE-ID will
+ // never change even if DTSTART for that instance changes), which
+ // I think is the right thing to do for CalDAV; I don't know what
+ // we'll do for incoming ITIP events though.
+ //
+ modifyException(anItem, aTakeOverOwnership) {
+ this.ensureBaseItem();
+
+ anItem = cal.unwrapInstance(anItem);
+
+ if (
+ anItem.parentItem.calendar != this.mBaseItem.calendar &&
+ anItem.parentItem.id != this.mBaseItem.id
+ ) {
+ cal.ERROR("recurrenceInfo::addException: item parentItem != this.mBaseItem (calendar/id)!");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (anItem.recurrenceId == null) {
+ cal.ERROR("recurrenceInfo::addException: item with null recurrenceId!");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let itemtoadd;
+ if (aTakeOverOwnership && anItem.isMutable) {
+ itemtoadd = anItem;
+ itemtoadd.parentItem = this.mBaseItem;
+ } else {
+ itemtoadd = anItem.cloneShallow(this.mBaseItem);
+ }
+
+ // we're going to assume that the recurrenceId is valid here,
+ // because presumably the item came from one of our functions
+
+ let exKey = getRidKey(itemtoadd.recurrenceId);
+ this.mExceptionMap[exKey] = itemtoadd;
+ },
+
+ getExceptionFor(aRecurrenceId) {
+ this.ensureBaseItem();
+ // Interface calIRecurrenceInfo specifies result be null if not found.
+ // To avoid strict "reference to undefined property" warning, appending
+ // "|| null" gives explicit result in case where property undefined
+ // (or false, 0, null, or "", but here it should never be those values).
+ return this.mExceptionMap[getRidKey(aRecurrenceId)] || null;
+ },
+
+ removeExceptionFor(aRecurrenceId) {
+ this.ensureBaseItem();
+ delete this.mExceptionMap[getRidKey(aRecurrenceId)];
+ },
+
+ getExceptionIds() {
+ this.ensureBaseItem();
+
+ let ids = [];
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ ids.push(item.recurrenceId);
+ }
+ return ids;
+ },
+
+ // changing the startdate of an item needs to take exceptions into account.
+ // in case we're about to modify a parentItem (aka 'folded' item), we need
+ // to modify the recurrenceId's of all possibly existing exceptions as well.
+ onStartDateChange(aNewStartTime, aOldStartTime) {
+ // passing null for the new starttime would indicate an error condition,
+ // since having a recurrence without a starttime is invalid.
+ cal.ASSERT(aNewStartTime, "invalid arg!", true);
+
+ // no need to check for changes if there's no previous starttime.
+ if (!aOldStartTime) {
+ return;
+ }
+
+ // convert both dates to UTC since subtractDate is not timezone aware.
+ let timeDiff = aNewStartTime
+ .getInTimezone(cal.dtz.UTC)
+ .subtractDate(aOldStartTime.getInTimezone(cal.dtz.UTC));
+
+ let rdates = {};
+
+ // take RDATE's and EXDATE's into account.
+ const kCalIRecurrenceDate = Ci.calIRecurrenceDate;
+ let ritems = this.getRecurrenceItems();
+ for (let ritem of ritems) {
+ let rDateInstance = cal.wrapInstance(ritem, kCalIRecurrenceDate);
+ let rRuleInstance = cal.wrapInstance(ritem, Ci.calIRecurrenceRule);
+ if (rDateInstance) {
+ ritem = rDateInstance;
+ let date = ritem.date;
+ date.addDuration(timeDiff);
+ if (!ritem.isNegative) {
+ rdates[getRidKey(date)] = date;
+ }
+ ritem.date = date;
+ } else if (rRuleInstance) {
+ ritem = rRuleInstance;
+ if (!ritem.isByCount) {
+ let untilDate = ritem.untilDate;
+ if (untilDate) {
+ untilDate.addDuration(timeDiff);
+ ritem.untilDate = untilDate;
+ }
+ }
+ }
+ }
+
+ let startTimezone = aNewStartTime.timezone;
+ let modifiedExceptions = [];
+ for (let exid of this.getExceptionIds()) {
+ let ex = this.getExceptionFor(exid);
+ if (ex) {
+ ex = ex.clone();
+ // track RECURRENCE-IDs in DTSTART's or RDATE's timezone,
+ // otherwise those won't match any longer w.r.t DST:
+ let rid = ex.recurrenceId;
+ let rdate = rdates[getRidKey(rid)];
+ rid = rid.getInTimezone(rdate ? rdate.timezone : startTimezone);
+ rid.addDuration(timeDiff);
+ ex.recurrenceId = rid;
+ cal.item.shiftOffset(ex, timeDiff);
+ modifiedExceptions.push(ex);
+ this.removeExceptionFor(exid);
+ }
+ }
+ for (let modifiedEx of modifiedExceptions) {
+ this.modifyException(modifiedEx, true);
+ }
+ },
+
+ onIdChange(aNewId) {
+ // patch all overridden items' id:
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ item.id = aNewId;
+ }
+ },
+};